Webhooks
TurnStay sends real-time notifications to your server when events occur — payment completions, transaction updates, refunds, and more.
TurnStay has two webhook surfaces. Subscribe to the events for the product you integrate:
| Surface | Examples | Docs |
|---|---|---|
| Merchant of Record | merchant_of_record.payment_intent.succeeded, refunds, MOR payouts | Payment Events |
| Treasury | treasury.payment.completed, treasury.withdraw.completed, deposits, transfers | Treasury Events |
Receiving MOR events on an endpoint does not mean Treasury events (such as claim confirmations) are delivered. Each event type must be subscribed explicitly.
Create webhook endpoints in the TurnStay Dashboard under Settings > Webhooks, then subscribe each endpoint to the event types it should receive.
You can also manage endpoints with your API key:
curl -X POST "https://prod.turnstay.com/api/v1/webhooks/endpoints" \ -H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/webhooks/turnstay", "name": "Bookings"}'The response includes the endpoint’s signing secret:
{ "id": 42, "url": "https://example.com/webhooks/turnstay", "name": "Bookings", "enabled": true, "account_id": null, "secret": "whsec_Xk3p9QvT2mLr8YbN4cWz7HfJ1sDg6AeU"}Signing secret
Section titled “Signing secret”Every endpoint has its own signing secret, a string that starts with whsec_. TurnStay uses it to sign every request it sends to that endpoint, so you can confirm the request really came from TurnStay and was not modified in transit.
- Where to find it: it is returned as
secretwhen the endpoint is created. You can reveal it again at any time on the endpoint’s page in the Dashboard, or withGET /api/v1/webhooks/endpoints/{endpoint_id}/secret. - Keep it server-side. Store it like an API key (environment variable or secrets manager). Never put it in client-side code or commit it to source control.
- One secret per endpoint. If you have several endpoints, each one has a different secret.
Rolling the secret
Section titled “Rolling the secret”Roll the secret from the endpoint’s page in the Dashboard, or with POST /api/v1/webhooks/endpoints/{endpoint_id}/secret/roll. The response contains the new secret.
Rolling takes effect immediately: every delivery after the roll, including retries of earlier events, is signed with the new secret, and the old secret stops working. Update the secret in your application straight after rolling. Deliveries that fail verification in the meantime are retried (see Retry behaviour), and you can retry them manually from the Dashboard.
Verifying signatures
Section titled “Verifying signatures”Every webhook request includes a Turnstay-Signature header:
Turnstay-Signature: t=1767225600, v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Part | Meaning |
|---|---|
t | Unix timestamp (seconds) when TurnStay signed the request. |
v1 | HMAC-SHA256 signature, hex-encoded (lowercase). |
TurnStay computes the signature as:
v1 = hex( HMAC_SHA256( key = signing_secret, message = "{t}.{raw_request_body}" ) )To verify a request:
- Read the
Turnstay-Signatureheader and split it on,. Trim whitespace, then split each part on the first=to gettandv1. - Build the signed message: the
tvalue, a.character, then the raw request body exactly as received. - Compute an HMAC-SHA256 of that message, using your full signing secret (including the
whsec_prefix, encoded as UTF-8) as the key. Hex-encode the result. - Compare your result with
v1using a constant-time comparison. If they differ, reject the request. - Check that
tis recent (we recommend within 5 minutes of your server clock) to protect against replayed requests.
Each delivery attempt is signed again with a new timestamp, so retries pass the timestamp check.
Python (FastAPI)
Section titled “Python (FastAPI)”import hashlibimport hmacimport jsonimport osimport time
from fastapi import FastAPI, Request, Response
app = FastAPI()WEBHOOK_SECRET = os.environ["TURNSTAY_WEBHOOK_SECRET"] # whsec_...
def verify_signature(payload: bytes, sig_header: str, secret: str, tolerance: int = 300) -> bool: try: parts = dict(item.strip().split("=", 1) for item in sig_header.split(",")) timestamp, expected = parts["t"], parts["v1"] if abs(time.time() - int(timestamp)) > tolerance: return False except (KeyError, ValueError): return False signed = f"{timestamp}.".encode() + payload computed = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(computed, expected)
@app.post("/webhooks/turnstay")async def turnstay_webhook(request: Request): payload = await request.body() if not verify_signature(payload, request.headers.get("Turnstay-Signature", ""), WEBHOOK_SECRET): return Response(status_code=400)
event = json.loads(payload) if event["type"] == "merchant_of_record.payment_intent.succeeded": intent = event["data"]["object"] mark_booking_as_paid( reference=intent["merchant_reference"], amount=intent["billing_amount"], ) return Response(status_code=200)Node.js (Express)
Section titled “Node.js (Express)”const crypto = require("crypto");const express = require("express");
const app = express();const WEBHOOK_SECRET = process.env.TURNSTAY_WEBHOOK_SECRET; // whsec_...
function verifySignature(payload, sigHeader, secret, tolerance = 300) { const parts = Object.fromEntries( sigHeader.split(",").map((item) => { const [key, ...rest] = item.trim().split("="); return [key, rest.join("=")]; }) ); const timestamp = parts.t; const expected = parts.v1; if (!timestamp || !expected) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > tolerance) return false;
const computed = crypto .createHmac("sha256", secret) .update(`${timestamp}.`) .update(payload) .digest("hex"); const a = Buffer.from(computed, "hex"); const b = Buffer.from(expected, "hex"); return a.length === b.length && crypto.timingSafeEqual(a, b);}
app.post("/webhooks/turnstay", express.raw({ type: "application/json" }), (req, res) => { const signature = req.headers["turnstay-signature"] || ""; if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) { return res.status(400).send(); }
const event = JSON.parse(req.body.toString("utf8")); // handle event.type ... res.status(200).send();});Webhook payload format
Section titled “Webhook payload format”All webhooks follow this structure:
{ "id": "evt_98765", "object": "event", "type": "merchant_of_record.payment_intent.succeeded", "api_version": "2026-02-25", "created_at": "2026-03-01T10:15:30.123456+00:00", "data": { "object": { "id": "45678", "object": "payment_intent", "status": "PROCESSED", "billing_amount": 100000, "billing_currency": "ZAR", "processing_amount": 5200, "processing_currency": "EUR", "merchant_reference": "BOOKING-2026-001", "company_id": 10, "account_id": 123 } }}The top-level id identifies the event. It stays the same when a delivery is retried, so use it to detect duplicates.
Handling webhooks
Section titled “Handling webhooks”Your webhook endpoint must:
- Accept
POSTrequests with a JSON body. - Verify the
Turnstay-Signatureheader before trusting the payload. - Return a
2xxstatus within 10 seconds to acknowledge receipt. - Be publicly accessible (not behind a VPN).
- Handle duplicate events gracefully (idempotency).
See Verifying signatures for complete handler examples.
Retry behaviour
Section titled “Retry behaviour”If your endpoint does not return a 2xx status (or times out), TurnStay retries the delivery. Each event is attempted up to 5 times in total, with retries roughly 1 minute, 5 minutes, 30 minutes and 2 hours after the previous failure. Ensure your handler is idempotent — the same event may be delivered more than once.
Error responses
Section titled “Error responses”When an API request fails, TurnStay returns a JSON error response:
{ "detail": "billing_amount is required"}| HTTP status | Meaning |
|---|---|
400 | Bad request — check the detail field for the cause. |
401 | Unauthorized — invalid or missing API key. |
404 | Resource not found. |
405 | Method not allowed — check the HTTP method. |
422 | Validation error — invalid field values. |
500 | Server error — retry the request or contact support. |
Callbacks (legacy)
Section titled “Callbacks (legacy)”If you set callback_url when creating a Payment Intent, TurnStay also POSTs to {your_callback_url}/callback/payment/intent. See Payment Events for the callback payload format.
Webhooks are recommended over callbacks for new integrations.