A webhook endpoint is a URL of yours that we send a signed POST to when something happens. It is
the alternative to polling, it costs you no rate-limit quota, and it is how you hear about a change
in seconds instead of on your next sweep.
Registering one needs the webhooks:manage scope, which is a Pro feature.
| Method | Endpoint | Scope | Plan |
|---|---|---|---|
| GET | /api/v1/webhooksList webhook endpoints | webhooks:manage | Pro |
| POST | /api/v1/webhooksRegister a webhook endpoint | webhooks:manage | Pro |
| PATCH | /api/v1/webhooks/{id}Update a webhook endpoint | webhooks:manage | Pro |
| DELETE | /api/v1/webhooks/{id}Delete a webhook endpoint | webhooks:manage | Pro |
| GET | /api/v1/webhooks/{id}/deliveriesList delivery attempts Query: | webhooks:manage | Pro |
| POST | /api/v1/webhooks/{id}/deliveries/{deliveryId}/replayReplay a delivery | webhooks:manage | Pro |
The event catalogue
| Event | When it fires | data is |
|---|---|---|
appointment.created | An appointment was booked, however it was booked. | V1Appointment |
appointment.updated | An appointment moved: its time, service or staff member changed. The internal name for this is a reschedule; the public one describes what you sync. | V1Appointment |
appointment.confirmed | An appointment was confirmed. | V1Appointment |
appointment.cancelled | An appointment was cancelled, by the business or by the customer. | V1Appointment |
appointment.completed | An appointment was marked completed. | V1Appointment |
appointment.no_show | A customer did not turn up. | V1Appointment |
customer.created | A customer record was created, including by a booking that deduplicated to a new person. | V1Customer |
payment.succeeded | Money arrived for an appointment. | V1Payment |
payment.refunded | Money was returned. Refunds are issued from the dashboard, never over this API. | V1Payment |
webhook.testnot subscribable | A reachability ping. Not a business event, and not subscribable. | { "object": "test" } |
data is the same JSON the corresponding endpoint returns. A customer.created payload is
byte-for-byte what GET /api/v1/customers/{id} would have answered with, because it is produced by
the same serializer rather than by a second one kept in step by hand.
You subscribe per event type, and an event type you did not subscribe to is never sent.
The envelope
Every delivery, including the ping, is wrapped the same way:
{
"id": "evt_01KYYR2ZV7A7M21DPZ2ABW10RM",
"type": "customer.created",
"createdAt": "2026-08-01T13:26:48.423Z",
"apiVersion": "v1",
"data": { "id": "db3a5336-e707-48df-8fac-4e35b8edcfb2", "firstName": "Php" }
}
id is a ULID-shaped string that sorts in the same order as its timestamp. It is the value to
deduplicate on, and it is also in the webhook-id header. Store it: a replay reuses the same id
deliberately, so a consumer that deduplicates will correctly ignore one.
apiVersion is v1 because the payload is the v1 serializer's output. When there is a v2 there
will be a v2 envelope, and this field is what lets you tell.
Headers
| Header | Value |
|---|---|
webhook-id | The event id. Your idempotency key |
webhook-timestamp | Unix seconds. Reject anything more than five minutes old |
webhook-signature | v1,<base64 HMAC-SHA256>, possibly several separated by spaces |
user-agent | Stippa-Webhooks/1.0 |
content-type | application/json |
Verifying the signature
This is the part that costs people an afternoon, so it is worked all the way through in two languages.
We sign to the Standard Webhooks specification, which means
you should not write any of this yourself. Point an existing library at it, hand it the whole
whsec_... string, and you are done:
import { Webhook } from 'svix';
// The RAW body. Not a parsed object, and not JSON.stringify of one.
app.post('/stippa-webhook', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = new Webhook(process.env.STIPPA_WEBHOOK_SECRET).verify(req.body, {
'webhook-id': req.header('webhook-id'),
'webhook-timestamp': req.header('webhook-timestamp'),
'webhook-signature': req.header('webhook-signature'),
});
} catch {
return res.status(400).end();
}
res.status(200).end(); // Acknowledge first.
void handle(event); // Then do the work.
});
The key is the decoded secret, not the string. Strip whsec_ and base64-decode the remainder;
those bytes are the HMAC key. This is what every conforming library does for you, and it is the one
detail to get right if you implement it by hand.
By hand, in Node
Only if you cannot add a dependency. Signed content is {webhook-id}.{webhook-timestamp}.{raw body},
joined with literal dots.
import { createHmac, timingSafeEqual } from 'crypto';
export function verifyStippaWebhook(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const header = headers['webhook-signature'];
if (!id || !timestamp || !header) return false;
// Check freshness BEFORE the HMAC, so a stale replay never reaches the comparison.
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) return false;
// The key is the DECODED secret. Not the `whsec_...` string.
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
const expectedBuffer = Buffer.from(expected);
// Several space-separated signatures during a rotation. Any one matching is a pass.
return header.split(' ').some((candidate) => {
const given = Buffer.from(candidate.replace(/^v1,/, ''));
// timingSafeEqual throws on a length mismatch, which is itself a timing signal.
return given.length === expectedBuffer.length && timingSafeEqual(given, expectedBuffer);
});
}
By hand, in PHP
function stippa_verify_webhook(string $rawBody, array $headers, string $secret): bool
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signature = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '' || $signature === '') {
return false;
}
// Reject anything outside five minutes BEFORE comparing, so a stale replay never reaches
// the HMAC at all.
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
// Strip the `whsec_` prefix and base64-decode the rest. The KEY is those bytes, not the
// string. `true` makes base64_decode strict, so a malformed secret fails loudly instead of
// silently decoding to the wrong bytes.
$key = base64_decode(substr($secret, strlen('whsec_')), true);
if ($key === false) {
return false;
}
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
// The header may carry several space-separated signatures during a secret rotation. Any one
// matching is a pass, and hash_equals is the constant-time comparison.
foreach (explode(' ', $signature) as $candidate) {
if (hash_equals($expected, substr($candidate, strlen('v1,')))) {
return true;
}
}
return false;
}
Pass true as the third argument to base64_decode. Without it PHP silently drops characters
outside the standard alphabet instead of failing, so a malformed secret becomes a wrong key and every
signature fails for a reason nothing tells you about.
Three ways this goes wrong
Signing the re-serialized body. json_encode or JSON.stringify of a parsed object is not the
bytes that arrived: key order, spacing and number formatting all differ. Capture the raw body before
anything parses it. In Express that means express.raw; most frameworks have an equivalent, and it is
usually the thing you forgot.
Keying on the prefixed string. Covered above and worth repeating because it fails in the way hardest to debug: everything looks right, the code is the same shape as every example you have seen, and the MAC simply does not match.
Comparing with ===. Use a constant-time comparison. A byte-by-byte compare that returns early
leaks how much of a forged signature was correct.
Answer fast, then work
Your endpoint has ten seconds. A slower answer counts as a failure and gets retried, which means slow processing turns one event into six.
Acknowledge with any 2xx as soon as you have the body safely somewhere, then process
asynchronously. Anything else and a queue backlog on your side becomes a retry storm on ours.
The retry ladder
| Attempt | Waits before it | Time since the event |
|---|---|---|
| 1 | immediate | 0 |
| 2 | 1 minutes | 1 minutes |
| 3 | 5 minutes | 6 minutes |
| 4 | 30 minutes | 36 minutes |
| 5 | 2 hours | 2.6 hours |
| 6 | 12 hours | 14.6 hours |
6 attempts in total, spanning 14.6 hours from the event. Each attempt gives your server 10 seconds to answer before it counts as a failure.
Retried: every 5xx, plus 408 and 429. A 429 from you is honoured, so a Retry-After header
shorter than twelve hours is used instead of our next rung.
Not retried: any other 4xx. A 400 or a 404 will never succeed, and fifteen hours of retries is
noise in your logs and ours. A 3xx is also not retried, because we send with redirects disabled
deliberately: a redirect to an internal address is how an SSRF check gets bypassed, so a redirecting
endpoint is a misconfigured one.
Auto-disable
Ten exhausted deliveries in a row and the endpoint is switched off, its status becomes
auto_disabled, and the business owner is emailed. consecutiveFailures is the counter and any
successful delivery resets it to zero.
Re-enable it with PATCH /api/v1/webhooks/{id} and isActive: true, which clears the failure count
in the same write, so it cannot come back one failure away from being switched off again. The owner
has a button for the same thing.
Events that fired while an endpoint was disabled are not backfilled. Catch up with
updatedSince on the resource you missed.
Replay
POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/replay re-sends a stored envelope. It is for a
consumer that never received the first attempt, not a way to make something happen twice: the
webhook-id is the original, so a consumer deduplicating on it will ignore the replay.
The replayed body is not byte-identical to the original, and its signature still verifies. The
envelope is stored as jsonb, so re-serializing it reorders the keys. Observed on a real replay: the
original arrived with data third and its members in serializer order, and the replay arrived with
data second and its members reordered entirely.
The signature is computed over the bytes actually sent, so it matches. Two consequences. Never store
a signature and re-check it against a body you re-encoded. And when you read payload from the
delivery log, compare parsed objects rather than bytes: the values are exactly what your server
received, the key order is not.
Replays are rate-limited per business rather than per key, at ten a minute, because the owner's button in the dashboard draws on the same bucket.
Testing before anything real happens
webhook.test is a ping in the same signed envelope as a real event, sent when you register an
endpoint, when its URL changes, and when the owner presses the test button. Its data is
{ "object": "test" }.
It exists so your verification code meets a real signature during development rather than in
production. Registration requires the ping to be answered with a 2xx, so an endpoint cannot be
stored active without having been proven reachable, and a 4xx from your server at registration time
is a 400 here naming url.
There is no test mode and no sandbox. Your keys read and write live data, so build the webhook consumer against pings and read-only calls first, and only add write scopes once the read path works.
Constraints worth knowing before you design around them
HTTPS only, on port 443. Plain HTTP is refused at registration, as is any other port.
Five endpoints per business. GET /api/v1/webhooks therefore takes no pagination parameters: a
cursor there would be a pager that can never turn a page. The shape is still the collection envelope,
with hasMore always false.
One URL per business. Registering a URL that is already registered is a 409 naming it. That is
also why POST /api/v1/webhooks is the one creating endpoint that takes no Idempotency-Key:
Zapier and tools like it send none, and the uniqueness constraint does the same job.
The secret is shown once, in the 201 from the registration, and stored nowhere you can read it
back. Lose it and the recovery is to delete the endpoint and register again. There is no rotation
endpoint; the multi-signature header format exists so that one can be added later without every
consumer changing first.
The URL is re-resolved at delivery time, not only at registration, against a blocklist of private
and link-local ranges. A hostname that resolves publicly today and to 169.254.169.254 tomorrow is
refused at the moment of delivery.
When it is not arriving
The delivery log is the shared view of reality between you and the business:
curl "https://stippa.nl/api/v1/webhooks/960d06d7-f275-4085-89b4-a8481713b6d3/deliveries?limit=20" \
-H "Authorization: Bearer $STIPPA_KEY"
{
"id": "ddfa39d2-395f-495d-9e33-cd81f6017a27",
"endpointId": "960d06d7-f275-4085-89b4-a8481713b6d3",
"eventId": "evt_01KYYR2ZV7A7M21DPZ2ABW10RM",
"eventType": "customer.created",
"status": "delivered",
"attempt": 1,
"responseStatus": 200,
"responseBody": "{\"ok\":true}",
"errorMessage": null,
"durationMs": 5,
"nextRetryAt": null,
"deliveredAt": "2026-08-01T13:26:48.448Z",
"payload": { "id": "evt_01KYYR2ZV7A7M21DPZ2ABW10RM", "type": "customer.created" },
"createdAt": "2026-08-01T13:26:48.423Z"
}
Every attempt is a row, kept for 30 days. status is pending before the first attempt, delivered
on a 2xx, failed while a retry is scheduled, and exhausted when the ladder ran out or the status
was never going to succeed. responseBody is truncated at 2 KB with the cut marked, which is usually
enough to see your own error page.
attempt above the ladder's ceiling means somebody used the replay endpoint.
The business owner sees the same log in the product, with the payload expanded, so "it is not arriving" is a question you can both answer from the same rows rather than from each other's descriptions.