Skip to content

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:

SurfaceExamplesDocs
Merchant of Recordmerchant_of_record.payment_intent.succeeded, refunds, MOR payoutsPayment Events
Treasurytreasury.payment.completed, treasury.withdraw.completed, deposits, transfersTreasury 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:

Terminal window
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"
}

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 secret when the endpoint is created. You can reveal it again at any time on the endpoint’s page in the Dashboard, or with GET /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.

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.

Every webhook request includes a Turnstay-Signature header:

Turnstay-Signature: t=1767225600, v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
PartMeaning
tUnix timestamp (seconds) when TurnStay signed the request.
v1HMAC-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:

  1. Read the Turnstay-Signature header and split it on ,. Trim whitespace, then split each part on the first = to get t and v1.
  2. Build the signed message: the t value, a . character, then the raw request body exactly as received.
  3. 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.
  4. Compare your result with v1 using a constant-time comparison. If they differ, reject the request.
  5. Check that t is 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.

import hashlib
import hmac
import json
import os
import 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)
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();
});

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.

Your webhook endpoint must:

  • Accept POST requests with a JSON body.
  • Verify the Turnstay-Signature header before trusting the payload.
  • Return a 2xx status 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.

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.

When an API request fails, TurnStay returns a JSON error response:

{
"detail": "billing_amount is required"
}
HTTP statusMeaning
400Bad request — check the detail field for the cause.
401Unauthorized — invalid or missing API key.
404Resource not found.
405Method not allowed — check the HTTP method.
422Validation error — invalid field values.
500Server error — retry the request or contact support.

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.