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

Recipe: a Slack message on every new booking

Register an endpoint, verify the signature, post to Slack. The natural upgrade from polling.

Last updated 1 August 2026

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.

On this page

  • 1. Write the receiver first
  • 2. Register the endpoint
  • 3. Post to Slack
  • 4. Make it survive
  • What we executed, and what we did not
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

······