> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tilta.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive instant HTTP POST notifications for Tilta events. Register endpoints, verify HMAC-SHA256 signatures, and handle automatic retries reliably.

Tilta webhooks deliver real-time event notifications to your backend as HTTP POST requests whenever something meaningful happens – a credit facility is approved, an order is confirmed, or an invoice becomes due. Instead of polling the API, you register an endpoint and Tilta pushes updates to you automatically, keeping your platform in sync without unnecessary API calls.

## How webhooks work

When a subscribed event occurs, Tilta sends an HTTP POST request to your registered endpoint with a JSON body describing the event. Your endpoint must respond with an HTTP `2xx` status code within a reasonable timeout to acknowledge receipt. If it does not, Tilta will retry the delivery automatically.

### Event object structure

Every webhook payload follows the same envelope structure regardless of event type:

```json theme={null}
{
  "id": "14da1660-f340-485f-afd0-7c942501d302",
  "occurred_at": 1701862836,
  "type": "ORDER.CONFIRMED",
  "data": {
    "external_id": "order1",
    "status": "CONFIRMED"
  }
}
```

<ResponseField name="id" type="string" required>
  A UUID uniquely identifying this event delivery. Use this field to implement idempotency – log processed IDs and skip any event you have already handled.
</ResponseField>

<ResponseField name="occurred_at" type="integer" required>
  Unix timestamp (seconds) of when the event occurred on Tilta's side. Use this alongside the resource ID to determine the most recent state when events arrive out of order.
</ResponseField>

<ResponseField name="type" type="string" required>
  The dot-separated event type in `RESOURCE.PROCESS.OUTCOME` format – for example, `FACILITY.CREATION.ACCEPTED` or `ORDER.CONFIRMED`. See the [Event Reference](/docs/webhook-events) for all available types.
</ResponseField>

<ResponseField name="data" type="object" required>
  Event-specific payload. The fields present depend on the event type. Refer to the [Event Reference](/docs/webhook-events) for the exact shape of each event's `data`.
</ResponseField>

***

## Event subscriptions

Tilta uses a hierarchical dot-separated naming convention – `RESOURCE.PROCESS.OUTCOME` – which allows you to subscribe at any level of specificity. A subscription to a prefix automatically receives all descendant events.

| Subscription                 | Events received                                                                           |
| ---------------------------- | ----------------------------------------------------------------------------------------- |
| `FACILITY.CREATION.ACCEPTED` | Only that exact event                                                                     |
| `FACILITY.CREATION`          | `FACILITY.CREATION.IN_REVIEW`, `FACILITY.CREATION.ACCEPTED`, `FACILITY.CREATION.REJECTED` |
| `FACILITY`                   | All `FACILITY.*` events                                                                   |
| `ORDER`                      | All `ORDER.*` events                                                                      |

Subscribe only to the events your integration actually needs. Broad prefix subscriptions are convenient but increase the volume of webhook traffic your endpoint must handle.

***

## Setting up a webhook endpoint

<Steps>
  <Step title="Build and deploy your endpoint">
    Create an HTTPS endpoint on your server that accepts POST requests and returns a `2xx` HTTP status code promptly. Perform any heavy processing asynchronously after acknowledging receipt – do not block the response while you process the event.

    ```typescript theme={null}
    // Example Express.js endpoint
    app.post('/webhooks/tilta', express.raw({ type: 'application/json' }), (req, res) => {
      const signature = req.headers['tilta-signature'] as string;
      const payload = req.body.toString();

      if (!verifyWebhookSignature(payload, signature, process.env.TILTA_WEBHOOK_SECRET!)) {
        return res.status(401).send('Invalid signature');
      }

      const event = JSON.parse(payload);

      // Queue for async processing – respond immediately
      eventQueue.push(event);

      res.status(200).send('OK');
    });
    ```
  </Step>

  <Step title="Register your endpoint with Tilta">
    Call `POST /v1/webhooks` to subscribe your endpoint to one or more event types. The `type` field accepts either a full event type or a prefix.

    ```bash theme={null}
    curl -X POST https://api.tilta.io/v1/webhooks \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "url": "https://yourplatform.com/webhooks/tilta",
        "type": "FACILITY.CREATION"
      }'
    ```

    Tilta returns a subscription object that includes your signing secret. Store this secret securely – you will use it to verify incoming webhook signatures.
  </Step>

  <Step title="Implement signature verification">
    Every webhook request includes a `Tilta-Signature` header. Verify this signature before processing any event to ensure the request genuinely came from Tilta. See [Signature Verification](#signature-verification) below for the full implementation.
  </Step>

  <Step title="Test with the sandbox">
    Use the sandbox base URL (`https://api.tilta-sandbox.io`) to register test endpoints and trigger events without affecting production data. Verify that your endpoint correctly receives, validates, and processes events before going live.
  </Step>
</Steps>

***

## Signature verification

Tilta signs every webhook request so you can confirm it originated from Tilta and has not been tampered with. The signature is included in the `Tilta-Signature` HTTP header.

### Signature header format

```
Tilta-Signature: t=1701862836,v1=f371bc4a311f2b009eef952dd83ca80e2b60026c8e935592d0f9c308453c813e
```

The header contains two comma-separated fields:

* **`t`** – Unix timestamp (seconds) of when Tilta signed the request
* **`v1`** – HMAC-SHA256 hex digest of `{webhookPayload}.{webhookTimestamp}`, signed with your webhook signing secret

### How to verify

To verify a signature, reconstruct the expected HMAC using the raw request body, the timestamp from the header, and your signing secret. Compare the result to the `v1` value in the header using a timing-safe comparison to prevent timing attacks.

```typescript theme={null}
import crypto from 'crypto';

function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const parts = signature.split(',');
  const timestamp = parts.find(p => p.startsWith('t='))?.slice(2);
  const receivedSig = parts.find(p => p.startsWith('v1='))?.slice(3);

  if (!timestamp || !receivedSig) return false;

  // Reject events older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
  if (age > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${payload}.${timestamp}`)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(receivedSig)
  );
}
```

<Warning>
  Always reject webhook requests where the timestamp (`t`) is more than 5 minutes old. Tilta includes this timestamp so you can protect against **replay attacks** – where a legitimate captured request is resent maliciously. If you skip this check, an attacker could replay a valid past event to trigger duplicate processing on your platform.
</Warning>

### Rotating signing keys

Rotate your webhook signing secret periodically to limit the blast radius of a potential secret leak. Call the rotate endpoint and update your application configuration with the new secret before the old one expires.

```bash theme={null}
curl -X POST https://api.tilta.io/v1/webhooks/{type}/signature_key \
  -H "Authorization: Bearer YOUR_API_KEY"
```

***

## Retry behavior

If your endpoint does not return a `2xx` response – whether due to a server error, timeout, or any other failure – Tilta automatically retries the delivery. Retries continue for up to **12 hours** using exponential backoff, giving your platform time to recover from transient outages without losing events.

<Note>
  Because retries can result in the same event being delivered more than once, always implement idempotency in your event handler. Log each processed event `id` and skip any event whose `id` you have already seen.
</Note>

***

## Managing subscriptions

### List your subscriptions

```bash theme={null}
curl https://api.tilta.io/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY"
```

### Replace a subscription

Use `PUT /v1/webhooks/{type}` to update the endpoint URL or configuration for an existing subscription.

```bash theme={null}
curl -X PUT https://api.tilta.io/v1/webhooks/FACILITY.CREATION \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourplatform.com/webhooks/tilta-v2"
  }'
```

### Unsubscribe

```bash theme={null}
curl -X DELETE https://api.tilta.io/v1/webhooks/FACILITY.CREATION/unsubscribe \
  -H "Authorization: Bearer YOUR_API_KEY"
```

***

## Best practices

<Accordion title="Implement idempotent event handling">
  The same event can be delivered more than once due to retries. Before processing any event, check whether you have already handled an event with the same `id`. Store processed event IDs in a durable store (database, Redis, etc.) and skip duplicates. This prevents double-charging, duplicate fulfillment, or other unintended side effects.
</Accordion>

<Accordion title="Subscribe only to the events you need">
  Broad prefix subscriptions (e.g., subscribing to `FACILITY` instead of `FACILITY.CREATION.ACCEPTED`) send your endpoint every descendant event, including ones you may not care about. This increases traffic and processing load unnecessarily. Subscribe to the most specific type that covers your use case.
</Accordion>

<Accordion title="Do not assume event ordering">
  Tilta does not guarantee that events arrive in chronological order. Network conditions and retry timing can cause earlier events to arrive after later ones. Always use the `occurred_at` timestamp combined with the resource's external ID to determine the current state of a resource – do not rely solely on arrival order.
</Accordion>

<Accordion title="Respond quickly; process asynchronously">
  Your endpoint should acknowledge receipt by returning `200 OK` as fast as possible. Push the event onto a queue and process it in a background worker. If your handler performs slow database writes or downstream API calls synchronously, it risks timing out and causing Tilta to retry an event you have already received.
</Accordion>

<Accordion title="Rotate signing keys periodically">
  Treat your webhook signing secret like a password. Rotate it on a regular schedule using `POST /v1/webhooks/{type}/signature_key`. Update your deployed secret before discarding the old one to avoid a gap in verification. Store secrets in environment variables or a secrets manager – never hardcode them in source code.
</Accordion>

***

## API reference

| Method   | Endpoint                            | Description                                  |
| -------- | ----------------------------------- | -------------------------------------------- |
| `POST`   | `/v1/webhooks`                      | Subscribe to a webhook event or prefix       |
| `GET`    | `/v1/webhooks`                      | List all active subscriptions                |
| `PUT`    | `/v1/webhooks/{type}`               | Replace an existing subscription             |
| `DELETE` | `/v1/webhooks/{type}/unsubscribe`   | Unsubscribe from an event type               |
| `POST`   | `/v1/webhooks/{type}/signature_key` | Rotate the signing secret for a subscription |
