Skip to content
StippaStippa
FeaturesPricingDemoFAQContact
Log inGet started

Getting started

  • Introduction
  • Authentication
  • Permissions

Core concepts

  • Errors
  • Pagination
  • Idempotency
  • Rate limits

Resources

  • Appointments
  • Customers
  • Services
  • Staff
  • Availability
  • Products
  • Payments
  • Webhooks

Recipes

  • Recipe: book from your own website
  • Recipe: sync the diary into a spreadsheet or BI tool
  • Recipe: a Slack message on every new booking

Reference

  • Changelog
  • OpenAPI 3.1 document

Webhooks

Receive events as they happen, verify the signature correctly, and understand the retry ladder.

Last updated 1 August 2026

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.

MethodEndpointScopePlan
GET/api/v1/webhooks

List webhook endpoints

webhooks:managePro
POST/api/v1/webhooks

Register a webhook endpoint

webhooks:managePro
PATCH/api/v1/webhooks/{id}

Update a webhook endpoint

webhooks:managePro
DELETE/api/v1/webhooks/{id}

Delete a webhook endpoint

webhooks:managePro
GET/api/v1/webhooks/{id}/deliveries

List delivery attempts

Query: limitcursor

webhooks:managePro
POST/api/v1/webhooks/{id}/deliveries/{deliveryId}/replay

Replay a delivery

webhooks:managePro

The event catalogue

EventWhen it firesdata is
appointment.createdAn appointment was booked, however it was booked.V1Appointment
appointment.updatedAn 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.confirmedAn appointment was confirmed.V1Appointment
appointment.cancelledAn appointment was cancelled, by the business or by the customer.V1Appointment
appointment.completedAn appointment was marked completed.V1Appointment
appointment.no_showA customer did not turn up.V1Appointment
customer.createdA customer record was created, including by a booking that deduplicated to a new person.V1Customer
payment.succeededMoney arrived for an appointment.V1Payment
payment.refundedMoney was returned. Refunds are issued from the dashboard, never over this API.V1Payment
webhook.testnot subscribableA 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

HeaderValue
webhook-idThe event id. Your idempotency key
webhook-timestampUnix seconds. Reject anything more than five minutes old
webhook-signaturev1,<base64 HMAC-SHA256>, possibly several separated by spaces
user-agentStippa-Webhooks/1.0
content-typeapplication/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

AttemptWaits before itTime since the event
1immediate0
21 minutes1 minutes
35 minutes6 minutes
430 minutes36 minutes
52 hours2.6 hours
612 hours14.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.

On this page

  • The event catalogue
  • The envelope
  • Headers
  • Verifying the signature
  • By hand, in Node
  • By hand, in PHP
  • Three ways this goes wrong
  • Answer fast, then work
  • The retry ladder
  • Auto-disable
  • Replay
  • Testing before anything real happens
  • Constraints worth knowing before you design around them
  • When it is not arriving
StippaStippa

Appointment scheduling, without the hassle.

For whom

  • For salons
  • Beauty salons
  • Physiotherapists
  • Coaches
  • Personal trainers

Features

  • Booking widget
  • Online payments
  • Reminders
  • No-show prevention
  • Online calendar
  • Client management

Product

  • Features
  • Pricing
  • FAQ
  • Contact
  • System status

Legal

  • Privacy policy
  • Terms of service
  • Data processing agreement

© 2026 Stippa. All rights reserved.

De Rechter Software · Molenwater 20, 4511 BN Breskens · KvK 98466402 · btw NL005332100B80

······