Webhooks
Webhooks let Tourfold notify your systems in near real time when something happens ā a brand
is created, a tour is updated, a case is deleted. Instead of polling the API, you register an
HTTPS endpoint and Tourfold POSTs a signed JSON payload to it as events occur.
Tourfold webhooks follow the open Standard Webhooks
specification, so you can verify and consume them with the official
standardwebhooks libraries in most languages ā no Tourfold-specific SDK required.
At a glanceā
| Transport | HTTPS POST, Content-Type: application/json |
| Delivery | At-least-once ā the same event may arrive more than once |
| Ordering | Not guaranteed ā order events yourself with timestamp |
| Signing | Standard Webhooks v1 HMAC-SHA256 (headers below) |
| Idempotency key | The webhook-id header (stable across retries) |
| Success | Any HTTP 2xx response acknowledges receipt |
| Response deadline | Return 2xx within 5 seconds; queue longer work asynchronously |
| Destination | A public HTTPS URL; redirects are not followed |
| Retries | On by default ā 1 initial attempt plus up to 6 retries (7 attempts total; see Retries) |
1. Register an endpointā
You need an API bearer token and a receiver reachable over public HTTPS. First inspect the live event catalog for your workspace rather than hard-coding a list from this guide:
curl -fsS "https://api.tourfold.com/api/v2/webhooks/event-types" \
-H "Authorization: Bearer YOUR_TOKEN" \
| jq -r '.items[].type'
Then create an endpoint with
createWebhookEndpoint,
choosing exact events or subscription patterns:
curl -X POST "https://api.tourfold.com/api/v2/webhook-endpoints" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"display_name": "Alpine maintenance integration",
"endpoint_url": "https://maintenance.example.invalid/tourfold/events",
"signature_scheme": "HMAC_SHA256",
"enabled": true,
"retry": true,
"subscriptions": ["folder.*", "*.created"]
}'
Successful creation returns HTTP 201, a Location header for the new resource, and the endpoint:
{
"id": "00000000-0000-4000-8000-000000008001",
"display_name": "Alpine maintenance integration",
"endpoint_url": "https://maintenance.example.invalid/tourfold/events",
"signature_scheme": "HMAC_SHA256",
"enabled": true,
"retry": true,
"subscriptions": ["folder.*", "*.created"],
"invalid_subscriptions": [],
"created_at": "2026-08-20T08:40:00Z",
"updated_at": "2026-08-20T08:40:00Z",
"lock_version": 0,
"secret": "whsec_example_not_a_real_secret"
}
The signing secret (format whsec_ā¦) is returned only once, on creation and on
rotation. Store it immediately in your secret manager. Listing or
retrieving the endpoint later never reveals it.
Test the receiverā
Once the secret is installed in your receiver, trigger a signed connectivity test:
curl -X POST "https://api.tourfold.com/api/v2/webhooks/send-test" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"endpoint_id":"00000000-0000-4000-8000-000000008001"}'
{
"last_test_tried_at": "2026-08-20T09:50:00Z",
"test_was_successful": true
}
The call sends test.webhook immediately and synchronously. It bypasses subscriptions, also works
while the endpoint is disabled, and is never retried. A failed test is still recorded in
delivery failures. Testing updates endpoint health and advances its
lock_version, so retrieve the endpoint again before a concurrency-guarded update.
Destination requirementsā
In production, endpoint_url must:
- be an absolute
https://URL with no embedded credentials or fragment; - resolve to public IP addresses ā loopback, private, link-local, and reserved destinations are rejected; and
- remain publicly resolvable at delivery time. Tourfold resolves it again immediately before each attempt to prevent DNS rebinding.
Tourfold does not follow redirects. It allows about 3 seconds to establish a connection and 5 seconds for the response. Local plain-HTTP endpoints work only when a Tourfold development environment explicitly enables its unsafe local-delivery option.
Manage the endpoint lifecycleā
Use JSON Merge Patch to change an endpoint. Read the current endpoint first and send its
lock_version to avoid overwriting a concurrent edit:
curl -X PATCH \
"https://api.tourfold.com/api/v2/webhook-endpoints/00000000-0000-4000-8000-000000008001" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/merge-patch+json" \
-d '{
"subscriptions": ["folder.*", "*.created"],
"lock_version": 0
}'
- Omitted fields stay unchanged. A supplied
subscriptionsarray replaces the entire set;[]clears it. Explicitnullis invalid. - Each endpoint accepts up to 100 subscription patterns.
- A stale
lock_versionreturns HTTP409. Re-fetch the endpoint, merge your intended change, and retry with the new value. - Pause and resume deliveries by patching
enabledtofalseortrue. You can still run a test while paused. - Deleting an endpoint returns HTTP
204and permanently removes its configuration.
Manage endpoints with the Webhook Endpoints operations
(list, update subscriptions, rotate secret, delete), and confirm connectivity any time with
sendTestWebhook.
2. Event namesā
An event name is a resource path followed by a verb:
brand.created
folder.permissions.updated
custom_object.invoice.created
area.created
Two rules explain every name you will see:
- A dot means containment.
folder.permissionsis the permissions of a folder ā a distinct thing from the folder itself, with its own events. Sofolder.updated(the folder was edited) andfolder.permissions.updated(its access list changed) are different events with different audiences. - An underscore joins words inside one name segment.
stored_addressis one noun, not astoredcontaining anaddress.
The verb is always the last segment. created, updated and deleted are the baseline; some
resources may add state transitions they genuinely have. updated means "a property was edited" ā
it never encodes which property.
Plugin-owned resources use <plugin_key>.<resource>.<verb>. The prefix is part of resource_path,
not a separate field, so it remains visible in subscriptions, delivered payloads, and logs. Plugin
event types are runtime tenant capabilities: the event-type catalog returns them only when the
corresponding plugin is enabled for your tenant. They are intentionally not enumerated in the static
OpenAPI contract. Treat GET /api/v2/webhooks/event-types as the authoritative list you can enable.
Two conventions worth knowing:
deletedmeans the record is gone. Moving something to trash and restoring it are edits, so they arrive asupdated.- Custom objects are named by slug ā
custom_object.invoice.created. See Custom objects for what happens when a slug is renamed.
Get the live list for your workspace from
listWebhookEventTypes,
which returns each event's type along with its resource_path, verb, and definition_slug for
custom objects. The Webhooks section of the OpenAPI reference
renders the tenant-independent generic set with full payload schemas. Runtime-only plugin event
types are documented by the catalog response and by the plugin that provides them.
3. Subscribe with patternsā
A subscription is either an exact event name or a pattern. Patterns are how you subscribe broadly without listing every name:
| Pattern | Matches |
|---|---|
brand.created | exactly that event |
folder.* | every folder verb and every folder aspect, including folder.permissions.updated |
<plugin_key>.* | every resource and verb owned by one enabled plugin |
*.created | created on every resource |
* | everything |
custom_object.invoice.* | every event for the invoice custom object |
custom_object.*.created | created for every custom object |
The one asymmetry to internalise: a wildcard verb widens the path, a named verb pins it.
folder.* includes folder.permissions.updated, because you asked for the folder and everything
under it. folder.updated does not, because that names one event and permissions are a
different resource. If you want ACL changes, subscribe to them.
Prefixes match whole segments, so folder.* never matches a resource called folder_archive.
Likewise, <plugin_key>.* never matches a generic event such as vehicle.created. In contrast,
*.created and * are deliberately global and include events from enabled plugins as well as
generic events; the delivered type still contains the concrete plugin prefix.
Overlapping patterns are safe. If one endpoint subscribes to both folder.* and
folder.updated, a folder edit still produces exactly one delivery to it ā deliveries are
de-duplicated per endpoint, not per matching pattern.
A pattern that cannot match anything is rejected when you save it (HTTP 422), rather than
accepted and silently never delivered. A subscription that matches nothing is the most confusing
failure this API could produce, so it is refused up front.
4. Payload and headersā
Every delivery has the same envelope. The event-specific part lives at data.object:
{
"type": "brand.created",
"id": "1178a3d4-76c1-402d-bc51-0a411424eab2",
"event_id": "f820f8c7-3566-4c4d-a1a0-8ec2b288feab",
"timestamp": "2024-01-15T10:30:00Z",
"payload_version": 1,
"tenant_id": "34f5c98e-f430-457b-a812-92637d0c6fd0",
"actor": { "type": "USER", "id": "6b1e...c4a2" },
"request": { "id": "d7e56ac2-af7a-4e00-af77-9ffc5e02f3cb", "correlation_id": "02bde914-c402-4a49-95cd-e8a4944b85d3" },
"data": {
"object": {
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "Alpine Facility Services GmbH",
"email": "contact@alpine-facility-services.example.invalid",
"phone_number": "+43123456789"
}
}
}
| Field | Meaning |
|---|---|
type | The event name. |
id | This delivery's id ā the same value as the webhook-id header. |
event_id | The event's id. One event fanned out to three endpoints yields three ids and one event_id. |
timestamp | When the event occurred (RFC 3339) ā not when this attempt was signed. |
payload_version | Envelope version. See Versioning. |
actor | Who caused it: {"type":"USER","id":ā¦}, or {"type":"SYSTEM"}. Omitted when not captured. |
request | Correlation ids for support and tracing. Omitted when not applicable. |
data.object | The resource this event is about. |
data.previous_attributes | On an edit, the old values of the keys that changed. Absent otherwise. |
previous_attributesā
Only present on edits, and it contains only what changed:
"data": {
"object": { "id": "ā¦", "name": "2024 reports", "parent_id": null },
"previous_attributes": { "name": "2024", "parent_id": "9f8cā¦" }
}
Read it as "what these keys used to be". A key present with null means it genuinely used to be
empty ā a folder moved to the root reports "parent_id": null, which is different from the key
being absent (that key did not change).
What data.object guaranteesā
Exactly three things:
- The resource's identity is always there.
idfor a resource that has one, or a reference to its parent for a nested resource that does not ācomment.reactionhas no id of its own, so it carriescomment_id. - Within one
payload_version, keys are only ever added ā never removed, renamed, or retyped. - The key set is open. Treat unknown keys as normal and ignore the ones you do not use.
Notably not guaranteed: that data.object equals what GET returns for the same resource. It
often looks similar, and relying on that will eventually break you ā a deleted event has no GET
to be equal to, and plugin-specific fields can appear that no GET exposes. Read the fields you
need; ignore the rest.
Headersā
| Header | Description |
|---|---|
webhook-id | Unique message id (UUID). Stable across retries ā use it as your idempotency key. |
webhook-timestamp | Unix timestamp (seconds) when the delivery was signed. |
webhook-signature | The v1,-prefixed signature ā see below. |
request-id | Correlation id for support/tracing. |
5. Verify the signatureā
Always verify before trusting or parsing a payload. This complete FastAPI receiver uses the official Standard Webhooks library and verifies the exact request bytes:
import json
import os
from fastapi import FastAPI, HTTPException, Request, Response
from standardwebhooks import Webhook, WebhookVerificationError
app = FastAPI()
verifier = Webhook(os.environ["TOURFOLD_WEBHOOK_SECRET"])
@app.post("/tourfold/webhooks")
async def receive_tourfold_webhook(request: Request):
raw_body = await request.body()
headers = {
"webhook-id": request.headers.get("webhook-id", ""),
"webhook-timestamp": request.headers.get("webhook-timestamp", ""),
"webhook-signature": request.headers.get("webhook-signature", ""),
}
try:
verifier.verify(raw_body, headers)
except WebhookVerificationError as exc:
raise HTTPException(status_code=400, detail="Invalid webhook signature") from exc
event = json.loads(raw_body)
# In production, atomically deduplicate headers["webhook-id"] and enqueue
# durable work here before acknowledging the delivery.
print(event["type"])
return Response(status_code=204)
python -m pip install fastapi standardwebhooks uvicorn
TOURFOLD_WEBHOOK_SECRET='whsec_...' \
uvicorn receiver:app --host 0.0.0.0 --port 8000
The print is for demonstration only. In production, persist the webhook-id and enqueue
durable processing before returning 2xx, then do slower work asynchronously. Do not log signing
secrets or complete payloads that may contain customer data.
If you verify manually: the signature is v1, followed by the base64 HMAC-SHA256 of the
string {webhook-id}.{webhook-timestamp}.{raw_body}, where the key is your secret with the
whsec_ prefix stripped and the remainder base64-decoded, and raw_body is the exact bytes
received (do not re-serialize):
key = base64_decode(secret without the "whsec_" prefix)
signed_content = webhook_id + "." + webhook_timestamp + "." + raw_body
expected = "v1," + base64(hmac_sha256(key, signed_content))
Also enforce replay protection: reject deliveries whose webhook-timestamp falls outside a
tolerance window (5 minutes is typical).
6. Retries & failuresā
Delivery is at-least-once. A delivery succeeds when your endpoint returns any HTTP 2xx;
anything else (or a timeout / connection error) is a failure.
Endpoints retry by default (retry: true on the endpoint). After the initial attempt, a failed
delivery is retried up to 6 times with escalating backoff, for at most 7 total attempts:
| Retry | Delay after the previous attempt |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 30 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
Because every retry carries the same webhook-id, deduplicate on it: record processed ids
and ignore repeats. Combined with timestamp tolerance, this protects you from duplicate deliveries
and replays.
Ordering is not guaranteed. Retries and parallel delivery mean a later event can overtake an
earlier one ā never rely on arrival order; reconcile using timestamp and your own state. A
429 response is honored: Tourfold then backs off for at least 5 minutes, or your Retry-After
if that is longer. An endpoint created with retry: false is not retried ā it fails terminally on
the first miss.
After the final attempt a delivery is terminally failed, and there is no automatic
re-delivery ā recovery is your responsibility. Inspect recorded failures with
listDeliveryFailures
(one row per message that failed at least once, updated when another retry fails). This is not a
delivery ledger: it does not record successful attempts, and a later successful retry does not add
a success record. Therefore, absence does not prove success and presence alone does not prove that
the message ultimately failed. Use retry_state: EXHAUSTED to identify messages that spent their
attempt budget, then backfill from your own state if needed. Failure records are retained for about
30 days and then purged.
7. Rotating the secretā
Rotate a compromised or ageing secret with
rotateWebhookEndpointSecret.
The new whsec_⦠secret is returned once. Rotation takes effect immediately: the old secret is
invalid as soon as the operation succeeds, and subsequent deliveries carry one signature made with
the new secret. Coordinate the receiver update as a cutover and expect deliveries sent before the
receiver has the new secret to fail and follow the endpoint's retry policy.
8. Versioning and compatibilityā
The envelope is versioned by the integer payload_version. Event names carry no version
suffix.
Within a payload_version we make only backward-compatible changes:
- New fields may be added to
data.objector the envelope at any time ā ignore unknown fields so your integration keeps working when they appear. - A breaking change ships as a new
payload_version; the existing version keeps its contract.
Configure your parser to tolerate unknown properties.
:::note Changed from the previous scheme
Event names used to carry a .vN suffix (brand.created.v1), and breadth came from separate
"umbrella" events (resource.created.v1). Both are gone: versioning moved to the envelope's
payload_version, and breadth moved into subscription patterns.
A version suffix per name is what forced the change ā it cannot coexist with prefix patterns, since every version bump would silently stop matching a pattern a customer had already saved. :::
9. Custom objectsā
Custom object events are named by the definition's slug: custom_object.invoice.created.
Subscriptions, however, are stored against the definition's id. That difference is deliberate and has two consequences:
-
Renaming a definition does not break your subscription. It keeps matching. But the delivered
typechanges, because the name renders the current slug ā so do not hard-code a slug in routing logic you cannot update.Route on
data.object.definition_idinstead. Every custom-object event carries it, and it never changes for the life of the definition. Treattypeas the readable name that follows the current slug, anddefinition_idas the stable key:"data": { "object": { "id": "ā¦", "definition_id": "6b1e0f22-ā¦", "definition_slug": "invoice" } } -
Deleting and recreating a definition with the same slug does not revive an old subscription. The recreated definition is a different object with a new id.
You may write a subscription with either the slug or the definition id; both resolve to the same
stored subscription. When a definition is deleted, its subscriptions are reported back in id form
and listed under invalid_subscriptions on the endpoint, so you can see and remove them.
10. Production checklistā
Before enabling an endpoint for production traffic:
- Verify the signature over the raw request bytes before parsing JSON or trusting any field.
- Enforce a timestamp tolerance and keep receiver clocks synchronized.
- Put a unique constraint on
webhook-id; acknowledge a duplicate without applying it twice. - Persist or durably enqueue accepted deliveries before returning
2xx, and respond within 5 seconds. - Treat the payload as an open schema: ignore fields you do not recognize.
- Expect events to arrive more than once and out of order. Use
timestampand reconcile with current REST API state when sequence matters. - Monitor delivery failures, especially
retry_state: EXHAUSTED, and define a backfill procedure. The failure endpoint is not a complete delivery log. - Keep the signing secret in a secret manager and rehearse its immediate-cutover rotation.
- Pause the endpoint with
enabled: falseduring receiver maintenance if it cannot safely accept traffic.
11. Troubleshootingā
| Symptom | What to check |
|---|---|
Endpoint creation or update returns 422 | Use an absolute public HTTPS URL without credentials or a fragment. Confirm every DNS answer is public and that the host resolves. |
Subscription update returns 422 | Query the live event catalog, check pattern spelling, and keep the replacement set at 100 patterns or fewer. |
| Signature verification fails | Use the one-time secret for this endpoint, verify the unmodified raw bytes and all three webhook-* headers, check clock skew, and remember that rotation invalidates the old secret immediately. |
A test returns test_was_successful: false | Confirm public reachability, trusted TLS, and a 2xx response within 5 seconds. Redirects are not followed. Inspect the failure entry for test.webhook. |
A PATCH returns 409 after a test | The test updated endpoint health and advanced lock_version. GET the endpoint again, merge your change, and retry with the current version. |
| The same event is processed twice | At-least-once delivery is working as designed. Deduplicate on webhook-id, which remains stable across retries. |
| Events appear out of order | Delivery is parallel and retries can overtake newer attempts. Do not use arrival order; reconcile using timestamp and current resource state. |
invalid_subscriptions is non-empty | A referenced custom-object definition was deleted. PATCH subscriptions with the complete intended set, omitting the invalid entries. |
A failure shows retry_state: EXHAUSTED | Automatic attempts are finished. Repair the receiver, then reconcile or backfill from the REST API; there is no delivery replay endpoint. |
| No failure is listed | This does not prove delivery. Only failed messages are recorded, records expire after about 30 days, and successful attempts are not a searchable ledger. |