Developers
Connect a SoloPayLink business to its own website or system. Bookings and payments always happen on SoloPayLink; your website receives a signed message every time something changes. Website connections are being switched on for businesses step by step.
Book now button
The owner copies this from Your website in the SoloPayLink app (with their own booking link name):
<a href="https://solopay.link/your-link" data-solopaylink="your-link">Book now</a>
<script src="https://solopaylink.com/embed/v1.js" async></script>
- On computers the booking opens in a small secure window; on phones (or if the browser blocks the window) it opens in the same tab and comes back to your page afterwards.
- When the owner has saved the website address in Your website, the confirmation page offers Back to your site and your page shows "You're booked". Your page also receives a
solopaylink:bookedevent:window.addEventListener("solopaylink:booked", (e) => console.log(e.detail))with{ status, reference }— never personal data. - That event only tells your page what to show. Save bookings from the signed webhooks below, never from the browser.
- Open it from your own code with
SoloPayLink.open("your-link"). The script sets no cookies.
Booking webhooks
The business owner opens Your website in the SoloPayLink app, enters your receiving address (https:// only) and receives a signing secret that starts with whsec_. From then on SoloPayLink sends an HTTPS POST with a JSON body to that address for each booking change. Reply with any 2xx status within 10 seconds; do slow work after replying.
Webhooks follow the open Standard Webhooks specification, so its ready-made libraries work unchanged.
| Header | Meaning |
|---|---|
webhook-id | Event id (evt_…). The same on every retry: store it and ignore repeats. |
webhook-timestamp | Unix seconds when this attempt was signed. Reject anything more than 5 minutes away from your clock. |
webhook-signature | v1,<base64> — HMAC-SHA256 of id.timestamp.body. During a secret change two signatures are sent, separated by a space; accept either. |
solopaylink-event | The event type, for logging. |
Event types
| Type | When |
|---|---|
booking.confirmed | A booking is confirmed (paid, deposit paid, or pay later). |
booking.rescheduled | The time changed. data.previous holds the old local date and time. |
booking.cancelled | The booking was cancelled. |
booking.completed | The appointment took place. |
payment.deposit_paid, payment.paid | A deposit or the full amount was paid. |
payment.refund_pending, payment.partially_refunded, payment.refunded | Refund progress. |
payment.disputed | The customer opened a card dispute. |
test.ping | The owner pressed Send test. The body has "test": true and a fake booking — do not store it. |
Events can arrive out of order (for example after retries). Use created_at to keep the newest state, and treat SoloPayLink as the owner of the customer's emails and calendar event: your system should not send its own booking confirmation or create a second calendar entry.
Payload
{
"id": "evt_3f1c…",
"type": "booking.confirmed",
"api_version": "2026-11-01",
"created_at": "2028-01-01T02:15:00.000Z",
"livemode": true,
"business": { "slug": "harbour-learner-driving" },
"data": {
"booking": {
"id": "bk_9a0c51d2e4f6a8b0c1d2e3f4",
"reference": "E5F6G7H8",
"status": "CONFIRMED",
"payment_status": "DEPOSIT_PAID",
"service": { "id": "svc_60", "name": "Driving lesson 60 min", "location_type": "at_customer" },
"start": "2028-01-03T23:00:00.000Z",
"end": "2028-01-04T00:00:00.000Z",
"timezone": "Australia/Brisbane",
"local_date": "2028-01-04",
"local_time": "09:00",
"duration_minutes": 60,
"customer": { "name": "Sam Learner", "email": "sam@example.com", "phone": null, "address": null, "note": null },
"answers": [],
"amounts": { "currency": "AUD", "total_cents": 7500, "paid_cents": 750, "refunded_cents": 0, "balance_due_cents": 6750 },
"payment": { "type": "deposit" },
"created_at": "2028-01-01T02:14:58.000Z"
}
}
}
Money is always in the smallest currency unit (cents). status is one of AWAITING_PAYMENT, CONFIRMED, COMPLETED, CANCELLED; payment_status one of UNPAID, DEPOSIT_PAID, PAID, REFUND_PENDING, PARTIALLY_REFUNDED, REFUNDED, DISPUTED. By default only the customer's name and email are included; the owner can choose to add phone, address, note and answers to their booking questions.
Verify the signature
Always verify before trusting a message. Use the raw request body exactly as received (before any JSON parsing).
Node.js
import crypto from "node:crypto";
export function verifySoloPayLink(rawBody, headers, secret) {
const id = headers["webhook-id"], ts = Number(headers["webhook-timestamp"]);
if (!id || !Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest();
return String(headers["webhook-signature"] || "").split(" ").some((part) => {
const [v, sig] = part.split(",");
const given = Buffer.from(sig || "", "base64");
return v === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
PHP
function verify_solopaylink(string $raw, array $h, string $secret): bool {
$id = $h['webhook-id'] ?? ''; $ts = (int)($h['webhook-timestamp'] ?? 0);
if ($id === '' || abs(time() - $ts) > 300) return false;
$key = base64_decode(preg_replace('/^whsec_/', '', $secret));
$expected = base64_encode(hash_hmac('sha256', "$id.$ts.$raw", $key, true));
foreach (explode(' ', $h['webhook-signature'] ?? '') as $part) {
[$v, $sig] = array_pad(explode(',', $part, 2), 2, '');
if ($v === 'v1' && hash_equals($expected, $sig)) return true;
}
return false;
}
Python
import base64, hashlib, hmac, time
def verify_solopaylink(raw: bytes, headers: dict, secret: str) -> bool:
msg_id, ts = headers.get("webhook-id", ""), int(headers.get("webhook-timestamp", "0"))
if not msg_id or abs(time.time() - ts) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw, hashlib.sha256).digest()).decode()
return any(p.split(",", 1)[0] == "v1" and hmac.compare_digest(p.split(",", 1)[1], expected)
for p in headers.get("webhook-signature", "").split() if "," in p)
Retries and pausing
- If your site does not answer
2xxwithin 10 seconds, SoloPayLink retries after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours (8 attempts, about 2 days).429withRetry-Afteris respected. - Redirects are not followed: give the final address.
- Answer
410 Goneto stop deliveries to an address immediately. - If deliveries keep failing for more than a day, SoloPayLink pauses the address and emails the owner. The owner can see every delivery, send it again, and turn sending back on from Your website.
- Signing secrets can be changed at any time; the previous secret keeps working for 24 hours.
AI agent booking API
AI assistants can find a SoloPayLink business, read its services and open times, and start a booking for a person through GET https://solopaylink.com/agent/v1 (REST) or POST https://solopaylink.com/agent/mcp (Model Context Protocol). The customer confirms with a code sent to their email and pays on the secure Stripe page. Start at /agent/v1 for the list of endpoints.
Questions? Contact support.