Skip to main content

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​

TransportHTTPS POST, Content-Type: application/json
DeliveryAt-least-once — the same event may arrive more than once
OrderingNot guaranteed — order events yourself with timestamp
SigningStandard Webhooks v1 HMAC-SHA256 (headers below)
Idempotency keyThe webhook-id header (stable across retries)
SuccessAny HTTP 2xx response acknowledges receipt
Response deadlineReturn 2xx within 5 seconds; queue longer work asynchronously
DestinationA public HTTPS URL; redirects are not followed
RetriesOn 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 subscriptions array replaces the entire set; [] clears it. Explicit null is invalid.
  • Each endpoint accepts up to 100 subscription patterns.
  • A stale lock_version returns HTTP 409. Re-fetch the endpoint, merge your intended change, and retry with the new value.
  • Pause and resume deliveries by patching enabled to false or true. You can still run a test while paused.
  • Deleting an endpoint returns HTTP 204 and 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.permissions is the permissions of a folder — a distinct thing from the folder itself, with its own events. So folder.updated (the folder was edited) and folder.permissions.updated (its access list changed) are different events with different audiences.
  • An underscore joins words inside one name segment. stored_address is one noun, not a stored containing an address.

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:

  • deleted means the record is gone. Moving something to trash and restoring it are edits, so they arrive as updated.
  • 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:

PatternMatches
brand.createdexactly 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
*.createdcreated on every resource
*everything
custom_object.invoice.*every event for the invoice custom object
custom_object.*.createdcreated 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"
}
}
}
FieldMeaning
typeThe event name.
idThis delivery's id — the same value as the webhook-id header.
event_idThe event's id. One event fanned out to three endpoints yields three ids and one event_id.
timestampWhen the event occurred (RFC 3339) — not when this attempt was signed.
payload_versionEnvelope version. See Versioning.
actorWho caused it: {"type":"USER","id":…}, or {"type":"SYSTEM"}. Omitted when not captured.
requestCorrelation ids for support and tracing. Omitted when not applicable.
data.objectThe resource this event is about.
data.previous_attributesOn 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:

  1. The resource's identity is always there. id for a resource that has one, or a reference to its parent for a nested resource that does not — comment.reaction has no id of its own, so it carries comment_id.
  2. Within one payload_version, keys are only ever added — never removed, renamed, or retyped.
  3. 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​

HeaderDescription
webhook-idUnique message id (UUID). Stable across retries — use it as your idempotency key.
webhook-timestampUnix timestamp (seconds) when the delivery was signed.
webhook-signatureThe v1,-prefixed signature — see below.
request-idCorrelation 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:

receiver.py
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:

RetryDelay after the previous attempt
130 seconds
22 minutes
310 minutes
430 minutes
51 hour
62 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.object or 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 type changes, 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_id instead. Every custom-object event carries it, and it never changes for the life of the definition. Treat type as the readable name that follows the current slug, and definition_id as 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 timestamp and 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: false during receiver maintenance if it cannot safely accept traffic.

11. Troubleshooting​

SymptomWhat to check
Endpoint creation or update returns 422Use an absolute public HTTPS URL without credentials or a fragment. Confirm every DNS answer is public and that the host resolves.
Subscription update returns 422Query the live event catalog, check pattern spelling, and keep the replacement set at 100 patterns or fewer.
Signature verification failsUse 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: falseConfirm 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 testThe 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 twiceAt-least-once delivery is working as designed. Deduplicate on webhook-id, which remains stable across retries.
Events appear out of orderDelivery is parallel and retries can overtake newer attempts. Do not use arrival order; reconcile using timestamp and current resource state.
invalid_subscriptions is non-emptyA referenced custom-object definition was deleted. PATCH subscriptions with the complete intended set, omitting the invalid entries.
A failure shows retry_state: EXHAUSTEDAutomatic attempts are finished. Repair the receiver, then reconcile or backfill from the REST API; there is no delivery replay endpoint.
No failure is listedThis does not prove delivery. Only failed messages are recorded, records expire after about 30 days, and successful attempts are not a searchable ledger.

Next steps​