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

# Webhooks

> Receive signed submission.status_changed events. Webhooks are the primary way to learn a submission's status.

Tera sends a signed `submission.status_changed` event to your HTTPS endpoint when a Supplier Submission's supplier-visible status changes. Webhooks are the **primary** status channel. Use [status reads](/guides/status-polling) only to recover a missed event or to reconcile your records.

Tera sends an event when:

* a commercial decision is made: `accepted`, `rejected`, or `duplicate_not_accepted`;
* billing moves from `pending` to `billable` or `not_billable`;
* a lead is returned ([Lead Return](/guides/lead-return));
* an enrolled document-first submission's `handoff` link becomes available.

Changes that land close together may arrive as one event carrying the latest status. Treat `data` as the current state, not a diff.

## Set up an endpoint

Tera staff configure webhook endpoints. There is no supplier self-serve portal.

* One endpoint per environment. Sandbox and production are configured, signed, and delivered separately.
* The URL must use `https`, resolve to a public host, and be at most 2048 characters. Tera rejects `localhost`, `.local` / `.internal` host names, private and loopback IP addresses, credentials in the URL, and `#fragments`.
* When the endpoint is created, Tera generates a signing secret that starts with `whsec_`. It is shown **once**. Store it in your secret manager. Tera cannot show it again.
* Tera staff can send a test event for one of your existing submissions in that environment. A test event is a normal `submission.status_changed` event carrying that submission's current status.

## Request

Each delivery is an HTTP `POST` with a JSON body.

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `Tera-Webhooks/1` |
| `Tera-Webhook-Id` | Event id. The same value as the envelope `id`. Stable across retries. |
| `Tera-Webhook-Timestamp` | Unix time in **seconds** when this attempt was signed. Changes on each retry. |
| `Tera-Webhook-Signature` | `v1=<hex HMAC-SHA256>`. May contain several comma-separated `v1=` entries. |

## Event envelope

```json theme={null}
{
  "id": "33333333-3333-4333-8333-333333333333",
  "type": "submission.status_changed",
  "occurred_at": "2026-08-29T14:40:00.000Z",
  "data": {
    "environment": "production",
    "submission_id": "22222222-2222-4222-8222-222222222222",
    "external_reference": "harbourview-enq-2001",
    "level": "application",
    "status": "returned",
    "received_at": "2026-08-29T10:15:02.000Z",
    "decided_at": "2026-08-29T14:40:00.000Z",
    "application_reference": "TBF-7K2M9QXA",
    "application_created_at": "2026-08-29T10:15:09.000Z",
    "billing": {
      "billing_status": "not_billable",
      "billable_at": null,
      "billing_model": "pre_underwriting_cleared",
      "not_billable_reason": "returned"
    },
    "return": {
      "reason_category": "outstanding_ccjs",
      "reason_category_version": "v1",
      "returned_at": "2026-08-29T14:40:00.000Z"
    }
  }
}
```

| Field | Meaning |
| - | - |
| `id` | Event id (UUID). Deduplicate on it. |
| `type` | `submission.status_changed`. Acknowledge and ignore any `type` you do not recognise. |
| `occurred_at` | When the status change happened. Use it to order events. |
| `data` | The same supplier-visible status object that `GET /v1/supplier-submissions/{submission_id}` returns, as it stood at `occurred_at`. |

`data` never includes `arrival_screen_url`; it is only returned by the [create response](/guides/application-submission).

`data.handoff` is included only for an enrolled document-first submission whose `status` is `accepted` and whose Journey Entry Link was usable when the event was created. It is a single-use bearer link to the customer journey: keep it out of logs, and treat it as expired if a retry reaches you late. If it is missing or no longer works, a [status read](/guides/status-polling) returns a fresh one. A new link on its own does not trigger another event.

## Verify the signature

Verify every request before you trust it.

<Steps>
  <Step title="Read the raw body">
    Read the request body as text **before** any JSON parsing. Re-serialising parsed JSON changes the bytes and breaks the signature.
  </Step>

  <Step title="Check the timestamp">
    Read `Tera-Webhook-Timestamp`. Reject the request if it is more than **300 seconds** from your current time. This limits replay.
  </Step>

  <Step title="Compute the expected signature">
    Compute HMAC-SHA256 over the string `{timestamp}.{rawBody}`, keyed with the full secret including the `whsec_` prefix (UTF-8). Hex-encode the digest.
  </Step>

  <Step title="Compare in constant time">
    Split `Tera-Webhook-Signature` on commas. Accept the request if **any** `v1=` entry matches your expected signature under a constant-time comparison. Ignore entries with other prefixes.
  </Step>
</Steps>

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const TOLERANCE_SECONDS = 300;

  export function verifyTeraWebhook({ secret, rawBody, timestamp, signatureHeader }) {
    if (!signatureHeader || !/^\d{1,12}$/.test(timestamp ?? "")) return false;
    const nowSeconds = Math.floor(Date.now() / 1000);
    if (Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) return false;

    const expected = createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`, "utf8")
      .digest();

    return signatureHeader
      .split(",")
      .map((entry) => entry.trim())
      .filter((entry) => entry.startsWith("v1="))
      .some((entry) => {
        const provided = Buffer.from(entry.slice(3), "hex");
        return provided.length === expected.length && timingSafeEqual(provided, expected);
      });
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  TOLERANCE_SECONDS = 300


  def verify_tera_webhook(secret: str, raw_body: bytes, timestamp: str, signature_header: str) -> bool:
      if not signature_header or not timestamp or not timestamp.isdigit():
          return False
      if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
          return False

      signed = timestamp.encode("utf-8") + b"." + raw_body
      expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

      for entry in signature_header.split(","):
          entry = entry.strip()
          if entry.startswith("v1=") and hmac.compare_digest(entry[3:], expected):
              return True
      return False
  ```
</CodeGroup>

Test your verifier against this synthetic vector. The secret is an example only.

| Input | Value |
| - | - |
| Secret | `whsec_example_do_not_use_in_production` |
| `Tera-Webhook-Timestamp` | `1788014402` |
| Raw body | The [envelope above](#event-envelope) serialised as compact JSON (no spaces or line breaks), in the order shown |
| `Tera-Webhook-Signature` | `v1=8ea7a4bc72c5bfd4c485cb8c06951e96ed17d1b4223e588d3b9096bb8925c2fe` |

Disable the timestamp check, or pin your clock to the timestamp, when you run the vector.

## Respond

* Return any **2xx** status within **10 seconds**. The response body is ignored.
* Do the work after you respond. Store the event, return `2xx`, then process it from your own queue.
* Any other status, a timeout, or a network error counts as a failed attempt.
* Tera does not follow redirects. A `3xx` response is a failed attempt. Configure the final URL.
* Return `2xx` for a duplicate event you have already processed.

## Retries

Tera retries a failed delivery up to **8 attempts** in total, with exponential backoff and jitter. The gaps between attempts are about 15 seconds, 45 seconds, 2 minutes, 7 minutes, 20 minutes, 1 hour and 1 hour, so the last attempt is roughly **2.5 hours** after the first.

Each retry is signed again with a fresh `Tera-Webhook-Timestamp`. `Tera-Webhook-Id`, the envelope `id`, and the body are unchanged.

After the final attempt fails, Tera stops delivering that event. Recover the current state with a [status read](/guides/status-polling).

Tera records an event only while an endpoint is active for that environment. Status changes that happen while no endpoint is configured, or while it is disabled, are not queued for later delivery.

## Idempotency and ordering

* **Deduplicate on `id`** (or `Tera-Webhook-Id`). Every retry of an event carries the same id. Process each id once.
* **Events can arrive out of order.** A retried older event can arrive after a newer one. Each event carries the full status object, so keep the event with the latest `occurred_at` per `submission_id` and ignore older ones.
* **Match on your reference.** `data.external_reference` and `data.submission_id` identify the submission. The envelope does not repeat them.

## Rotate the signing secret

Ask Tera staff to rotate the secret. Rotation replaces the secret **immediately** and shows the new `whsec_` value once. Deliveries signed after rotation, including retries of earlier events, use the new secret.

To avoid rejecting events during cutover, accept a signature that matches either the old or the new secret for a short window, then remove the old secret.

## Status reads still work

`GET /v1/supplier-submissions/{submission_id}` and `GET /v1/supplier-submissions?external_reference=` stay available. Use them to:

* recover after a delivery fails or your endpoint is down;
* reconcile your records on a schedule;
* read a fresh `handoff.customer_journey_url` for an enrolled document-first submission.

See [Status polling](/guides/status-polling).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.