Register an endpoint, the event types and payloads, signature verification, and retries.
Webhooks notify your server when something happens in Truss: a client uploads a file, a project becomes ready, an engagement letter is signed. Instead of polling the API for changes, you register an HTTPS endpoint and Truss sends it an HTTP POST for each event.
Truss delivers webhooks through Svix, so you get signed payloads, automatic retries, a delivery log, and manual replay without building any of it yourself.
Setting up an endpoint
- Sign in to Truss and open Account > Webhooks.
- Click Add Endpoint and enter the URL on your server that will receive events. It must be HTTPS and reachable from the internet.
- Choose which event types to subscribe to, or leave it open to receive all of them.
- Copy the endpoint's signing secret. You will need it to verify that incoming requests came from Truss.
The same page shows every delivery attempt to each endpoint, with the request body, the response your server returned, and a button to resend any message. Use it to debug a handler without waiting for the next real event.
Event types
| Event | Sent when | Payload |
|---|---|---|
project.created | A project is created. | The project (extended) |
project.modified | A project's attributes change. | The project (extended) |
project.ready | Every task on a project has been completed or approved. | The project (extended) |
status.modified | A project's status changes. | The project, plus previous_status and current_status names |
task.created | A task is added to a project. | The task (extended) |
task.modified | A task changes, including status changes. | The task (extended) |
file.created | A file is attached to a task. | File name, download URL, task and project ids |
comment.created | A comment is posted on a task. | The comment |
contact.created | A contact is created. | The contact (extended) |
contact.modified | A contact's attributes change. | The contact (extended) |
entity.created | An entity is created. | The entity (extended) |
entity.contact_linked | A contact is linked to an entity. | The contact (extended) |
entity.project_linked | A project is linked to an entity. | The project (extended) |
esignature.completed | Every signer has signed an e-signature request. | The request, its signers, and a link to the signed document |
stripe.complete | A client pays an invoice task. | The task (extended) |
The extended shapes are the same ones the API returns from the corresponding GET endpoint, so a handler can treat a webhook payload and an API response identically. Every event's schema is listed under Webhook Events in the API Reference.
Payload format
Each delivery is a JSON body with two keys. event_type names the event, and payload carries the object, wrapped in data the way API responses are.
{
"event_type": "task.modified",
"payload": {
"data": {
"id": 9120,
"name": "Upload your W-2 from the Prancing Pony",
"status": "pending_review",
"due_date": "2026-03-01",
"project": { "id": 48213, "name": "2025 Form 1040 - Frodo Baggins" },
"attached_files": [ "..." ]
}
}
}
Two events have payloads of their own shape:
{
"event_type": "file.created",
"payload": {
"file_name": "2025-W2-Baggins.pdf",
"file_url": "https://truss-uploads.s3.amazonaws.com/...",
"task_name": "Upload your W-2 from the Prancing Pony",
"task_id": 9120,
"project_id": 48213
}
}
The file_url in file.created is a download link valid for two days. For status.modified, payload.data is the project with previous_status and current_status added as status names; either can be "Empty" when the project had no status.
Verifying signatures
Anyone who learns your endpoint URL can send it requests, so verify every delivery before acting on it. Each request carries three headers:
| Header | Contents |
|---|---|
svix-id | A unique id for the message. The same id is reused on retries. |
svix-timestamp | When the message was sent, as a Unix timestamp. |
svix-signature | One or more Base64 HMAC-SHA256 signatures, space-separated, each prefixed with a version such as v1,. |
The signature is an HMAC-SHA256 over the string {svix-id}.{svix-timestamp}.{raw body}, keyed with your endpoint's signing secret. Compute it, compare it to each signature in the header with a constant-time comparison, and reject the request if none match or if the timestamp is more than a few minutes old.
The simplest way to do this correctly is to use a Svix library. They are available for most languages and handle the encoding and timestamp checks for you.
require "svix"
wh = Svix::Webhook.new(ENV.fetch("TRUSS_WEBHOOK_SECRET"))
payload = wh.verify(request.raw_post, request.headers) # raises on a bad signature
from svix.webhooks import Webhook
wh = Webhook(os.environ["TRUSS_WEBHOOK_SECRET"])
payload = wh.verify(request.data, request.headers) # raises on a bad signature
Verify against the raw request body, before any JSON parsing or re-serialization. The signature covers the exact bytes Truss sent. See Svix's guide to verifying payloads for the full algorithm.
Responding to a delivery
Return a 2xx status within 30 seconds. Anything else, including a timeout, counts as a failed attempt and is retried.
Do the minimum needed to record the event before you respond, and do the real work afterward. If your handler fetches the full object from the API, writes to your database, and sends an email, run those in a background job and acknowledge the webhook immediately.
Retries
When your endpoint fails to respond with a 2xx, Truss retries the delivery on a backoff schedule: 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours again. If every attempt fails, the message is marked failed and is kept in the Webhooks page where you can resend it manually once your endpoint is healthy. An endpoint whose deliveries all fail for five days is disabled automatically; re-enable it from the Webhooks page after fixing it.
Because of retries, your handler can receive the same event more than once. Make it idempotent. The svix-id header is stable across retries of the same message, so storing the ids you have processed and skipping duplicates is enough.
Ordering
Events are delivered independently and can arrive out of order, especially when a burst of changes happens at once or an earlier delivery is being retried. Don't assume that task.created arrives before the first task.modified for the same task. When order matters, treat each event as a prompt to fetch the object's current state from the API rather than as the state itself.