The job: the salon's team channel gets a message the moment somebody books. It is the most immediately useful thing you can build on this API, and it is a webhook receiver plus about ten lines.
You need: webhooks:manage, which is a Pro feature. No read scopes at all: the event carries
the whole appointment.
This is the natural upgrade from the sync recipe. That one polls and is minutes behind; this one arrives in seconds and costs no rate-limit quota.
1. Write the receiver first
Register the endpoint after the receiver is live, not before: registration sends a signed
webhook.test ping and requires a 2xx, so an endpoint you register too early is refused rather than
stored broken.
import express from 'express';
import { Webhook } from 'svix';
const app = express();
// The RAW body. If a JSON body parser runs first, the signature cannot verify: a re-serialized
// object is not the bytes we signed.
app.post('/stippa-webhook', express.raw({ type: 'application/json' }), async (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();
}
// Acknowledge inside ten seconds, then work. A slow answer is retried.
res.status(200).end();
if (event.type === 'appointment.created') void postToSlack(event.data);
});
app.listen(3000);
new Webhook(secret) takes the whole whsec_... string. It strips the prefix, base64-decodes the
remainder into the HMAC key, checks the timestamp tolerance and compares in constant time. Do not
implement that yourself unless you cannot add a dependency, and if you must, use the worked snippet on
the webhooks page.
The webhook.test ping arrives before any real event and in the same signed envelope, deliberately, so
your verification code meets a real signature during development. Handle an unrecognised type by
acknowledging it, not by throwing: webhook.test is not appointment.created and a receiver that
400s on it cannot be registered at all.
2. Register the endpoint
curl -X POST https://stippa.nl/api/v1/webhooks \
-H "Authorization: Bearer $STIPPA_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.example/stippa-webhook",
"eventTypes": ["appointment.created", "appointment.cancelled"],
"description": "Slack notifier"
}'
{
"id": "960d06d7-f275-4085-89b4-a8481713b6d3",
"url": "https://your-server.example/stippa-webhook",
"eventTypes": ["appointment.created", "appointment.cancelled"],
"isActive": true,
"status": "active",
"payloadStyle": "full",
"consecutiveFailures": 0,
"secret": "whsec_YGfAGeWcWiUe7A3wtGKNFTto6qVA0AzU"
}
secret is in this response and nowhere else. Put it in your environment now. It is not stored in
a readable form on our side, so nobody, including us on a support call, can read it back to you. Lose
it and the only recovery is to delete the endpoint and register again.
Note there is no Idempotency-Key on this call. It is the one creating endpoint that does not take
one, because Zapier and tools like it send none; the guarantee is a uniqueness constraint on the URL
instead, so registering twice is a 409 rather than two subscriptions doubling every message.
3. Post to Slack
async function postToSlack(appointment) {
const when = new Date(appointment.startTime).toLocaleString('nl-NL', {
timeZone: 'Europe/Amsterdam', // The business's zone, not your server's.
dateStyle: 'full',
timeStyle: 'short',
});
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `New booking: ${appointment.service.name} for ${appointment.customer.firstName} `
+ `${appointment.customer.lastName} on ${when}`
+ (appointment.staffMember ? ` with ${appointment.staffMember.name}` : ''),
}),
});
}
Everything the message needs is already in data: the appointment embeds its customer, service and
staff member, so there is not a single extra API call in this recipe.
Render the time in the business's timezone. startTime is a UTC instant, your server is probably
somewhere else, and a salon reading "14:00" for a 16:00 appointment will lose faith in the integration
immediately. GET /api/v1/me reports the business's timezone if you would rather not hardcode it.
An appointment.created payload contains a customer's name, email address and phone number, and a
Slack channel is a place that data will sit in for years and be searchable by everybody in the
workspace. That is the business's decision to make, not yours: tell the owner what the message will
contain before you point it at a shared channel, and consider posting only the first name and the
service.
4. Make it survive
Deduplicate on webhook-id. Delivery is at-least-once. A retry after your server answered slowly,
or a replay the owner triggered, will arrive with the same event id, and Slack has no idea it is a
duplicate. Keep the ids you have handled for a day or two and drop repeats.
Watch for auto-disable. Ten exhausted deliveries in a row and we switch the endpoint off and email
the owner. The usual cause is a deploy that broke the route, and the symptom is that messages stop
without an error anywhere in your logs. GET /api/v1/webhooks shows status: "auto_disabled" and
consecutiveFailures; re-enable with PATCH and isActive: true.
Nothing is backfilled. Events fired while the endpoint was disabled are gone. If the channel needs
to be complete, catch up with updatedSince on the appointments list after a re-enable.
What we executed, and what we did not
Every request on this page was run against a real server, and the receiver in step 1 was run for real:
a registration ping and several customer.created and appointment.created deliveries arrived at it,
and their signatures were verified by svix and, independently, by the PHP snippet on the
webhooks page. The 409 on a duplicate URL, the auto-disable counter and
the replay behaviour were all observed rather than reasoned about.
The one line we have not run is the fetch to SLACK_WEBHOOK_URL in step 3, because that is Slack's
API rather than ours and it needs a workspace. It is the documented shape for an incoming webhook and
nothing about it depends on Stippa. If it fails for you, the payload reaching postToSlack is the part
worth checking first, and it is the part above that has been verified.