# Webhooks (https://terac.com/docs/developers/guides/webhooks)

Subscribe to submission events, verify the signature, and handle retries.

Webhooks push submission events to your endpoint instead of you polling for them. The
contract is a conventional one: a signed POST, an idempotency header, and exponential
backoff on failure, so a receiver written for any mainstream provider works here with a
different header name.

One endpoint is one subscription: it has a URL, a list of the events it receives, and one
signing secret. Adding a second event to the same receiver is a change to that subscription,
not a second one, so a receiver never has more than one secret to verify with.

## Setting one up [#setting-one-up]

Two calls: create the subscription, then confirm it.

### 1. Create a subscription [#1-create-a-subscription]

```bash
curl -X POST https://terac.com/api/external/v2/hooks/subscriptions \
  -H "Authorization: Bearer $TERAC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "target_url": "https://yourapp.com/hooks/terac",
    "event_types": ["submission.status.change", "submission.approved"]
  }'
```

```json
{
  "id": "whs_123",
  "target_url": "https://yourapp.com/hooks/terac",
  "event_types": ["submission.status.change", "submission.approved"],
  "is_enabled": true,
  "confirmed_at": null,
  "disabled_at": null,
  "disabled_reason": null,
  "secret": "whsec_9f8e7d...",
  "created_at": "2026-08-11T21:00:00.000Z",
  "updated_at": "2026-08-11T21:00:00.000Z"
}
```

Terac generates the secret; you cannot supply your own. It is returned here and nowhere else
in a create response, but `GET /hooks/subscriptions/{id}/secret` reads it back in full, so
losing it does not force a rotation. Treat it like an API key.

`target_url` must be `https` and must resolve to a public host: private, loopback,
link-local and internal addresses are refused, because confirming makes Terac issue a request
to it. One URL can be subscribed once per organization: a second `POST` for a URL you already
registered returns `409` naming the existing subscription. A new subscription comes back with
`"confirmed_at": null` and receives nothing until you confirm it.

### 2. Confirm it [#2-confirm-it]

```bash
curl -X POST https://terac.com/api/external/v2/hooks/subscriptions/whs_123 \
  -H "Authorization: Bearer $TERAC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

Terac POSTs one signed `webhook.ping` to your `target_url`. Answer `2xx` and the
subscription starts receiving events; anything else returns `412` with the reason, and
nothing is confirmed.

> This is also how you test that Terac can reach your receiver. The ping carries
> the same signature headers as a real delivery, so verifying it proves your
> verification code works. Send it as often as you like. To see real submission
> events arrive, [test your receiver in a
> sandbox](https://terac.com/docs/developers/guides/webhooks#test-your-receiver-in-a-sandbox).

Note the `-d '{}'`: every `POST` on this API expects a JSON body and a `Content-Type`, even
when the endpoint takes only a path parameter. A bodyless `POST` returns `415`.

## Verifying a delivery [#verifying-a-delivery]

Every request carries four headers:

| Header                      | Meaning                                                                  |
| --------------------------- | ------------------------------------------------------------------------ |
| `X-Terac-Request-Signature` | `base64(HMAC-SHA256(secret, timestamp + raw body))`                      |
| `X-Terac-Request-Timestamp` | Unix seconds. Part of the signed string, so it changes on each retry.    |
| `X-Event-ID`                | Unique per delivery and **stable across retries**. Deduplicate on this.  |
| `X-Timestamp`               | ISO-8601, when the event happened. Stable across retries. Order by this. |

Sign the concatenation of the timestamp header and the **raw request body**, with no
separator. Parsing and re-serializing the JSON first will change the bytes and the signature
will not match.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, headers: Headers, secret: string): boolean {
  const timestamp = headers.get("X-Terac-Request-Timestamp");
  const signature = headers.get("X-Terac-Request-Signature");
  if (!timestamp || !signature) return false;

  // Reject anything too old to be a live delivery, so a captured request
  // cannot be replayed indefinitely.
  const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (ageSeconds > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(timestamp + rawBody)
    .digest("base64");

  const a = Buffer.from(expected, "base64");
  const b = Buffer.from(signature, "base64");
  // timingSafeEqual throws when the lengths differ, which a malformed
  // signature produces.
  return a.length === b.length && timingSafeEqual(a, b);
}
```

One secret verifies every event type the subscription carries, so this function does not need
to know which event it is looking at.

## Rotating the secret [#rotating-the-secret]

```bash
curl -X POST https://terac.com/api/external/v2/hooks/subscriptions/whs_123/secret \
  -H "Authorization: Bearer $TERAC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

The response carries the new secret, and it takes effect immediately with no overlap window:
the next attempt of every delivery, including one already queued, is signed with it. Deploy
the new secret to your receiver first, or accept the signature failures in between. Rotation
affects only this subscription.

## Event types [#event-types]

Read `GET /hooks/event-types` rather than hardcoding a list. New event types are added
without a breaking change and appear there first.

To change what an existing subscription receives, `PATCH` its `event_types`. The list is
replaced rather than added to, so send the full set you want:

```bash
curl -X PATCH https://terac.com/api/external/v2/hooks/subscriptions/whs_123 \
  -H "Authorization: Bearer $TERAC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_types": ["submission.status.change"]}'
```

### `submission.status.change` [#submissionstatuschange]

Every status transition, so one subscription follows the whole lifecycle.

```json
{
  "event_type": "submission.status.change",
  "event_id": "dlv_7h3k9",
  "resource_id": "sub_abc123",
  "occurred_at": "2026-08-11T21:00:00.000Z",
  "opportunity_id": "fy8auvdlx7ei5y5jdiy7w35z",
  "from": "screening",
  "to": "screened_out"
}
```

`to` uses the status vocabulary of `GET /submissions/{id}`, so you can always fetch the
submission the event names.

`from` uses that vocabulary plus two names for the phase before the work starts, which a
submission has no readable status in:

| `from`      | Meaning                                                                       |
| ----------- | ----------------------------------------------------------------------------- |
| `init`      | Nothing answered yet. The applicant entered and had not started the screener. |
| `screening` | Mid-screener: answering it, or answered and awaiting the outcome.             |

So `from` tells you things `to` cannot. `"from": "init", "to": "screened_out"` is an
applicant filtered out on their profile before ever seeing the screener, while
`"from": "screening", "to": "screened_out"` is one who answered it and did not qualify. The
same split separates a drop-out who never started from one who abandoned the screener
half-way.

A submission is not fetchable while it is in either of those two: a half-finished screener is
not readable through the API, the way it is not openable in the dashboard. That is why they
name a `from` and never a `to` — you do not receive an event for *entering* the screener, only
for leaving it.

To find out *why* someone was screened out, read `screening_answers` on the submission: each
answer carries the per-question verdict. For the rates rather than the individuals, read
`screening_stats` on the opportunity.

### `submission.approved` [#submissionapproved]

The same transition as `submission.status.change` with `"to": "approved"`, on its own event
type. Identical payload shape. Take this one alone if you only act on approvals: a submission
produces roughly five status changes, so filtering on your side means receiving five
deliveries to act on one. Taking both means an approval arrives twice, once under each event
type, with a different `X-Event-ID`.

## Test your receiver in a sandbox [#test-your-receiver-in-a-sandbox]

A sandbox organization is a separate test organization that Terac sets up for your integration.
If you do not have one, ask your Terac contact for one. Its dashboard shows a "Demo mode"
banner, and nobody is recruited, contacted or paid in it. When you launch an opportunity there,
Terac fills it with example submissions whose participant has already finished: screened out,
approved, or awaiting your review. None of them moves by itself, so your endpoint hears nothing
unless you approve or reject one.

A test submission is one that does move. You ask for it, Terac plays a fake participant
through the opportunity, and every step sends the webhook a real participant's step would
send, through the same delivery system: the same signature, the same retries, the same
delivery log.

### Before you start [#before-you-start]

The sandbox is its own organization, so it has its own API keys and its own webhook
subscriptions.

* **An API key from the sandbox.** Create it in the sandbox organization's settings, the way
  you created your first key. The examples below call it `$TERAC_SANDBOX_API_KEY`. The key of
  your production organization does not work here: it gets `404` for an opportunity in the
  sandbox, and `403` for one of its own.
* **A subscription in the sandbox.** A subscription receives the events of its own organization
  only, so one you created with your production key hears nothing from the sandbox.
  Create and confirm one with the sandbox key, following [Setting one up](https://terac.com/docs/developers/guides/webhooks#setting-one-up). It
  gets its own signing secret. The same `target_url` can be subscribed in both organizations,
  but a separate URL for the sandbox keeps each receiver to one secret.

### 1. Create a test submission [#1-create-a-test-submission]

The opportunity has to be active, and the API key has to belong to your sandbox organization.

```bash
curl -X POST https://terac.com/api/external/v2/opportunities/fy8auvdlx7ei5y5jdiy7w35z/test-submissions \
  -H "Authorization: Bearer $TERAC_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{
  "id": "sub_abc123",
  "opportunity_id": "fy8auvdlx7ei5y5jdiy7w35z",
  "task_url": null,
  "dashboard_url": "https://terac.com/acme-sandbox/onboarding/opportunities/fy8auvdlx7ei5y5jdiy7w35z/submissions"
}
```

Terac creates a participant called "Test Participant", with no email address and no phone
number, and a submission for it. The participant then takes its steps a few seconds apart: it
answers the screener, starts the task and finishes it.

A test submission is an ordinary submission in your sandbox:

* It shows in the opportunity's Submissions table in the dashboard, and in the applicant queue
  when it is waiting there, under the name "Test Participant". You can approve, reject, invite
  or decline it there as well as through the API.
* It counts in the opportunity's numbers, such as `submission_stats`. A sandbox opportunity stays
  open when its approvals reach or pass its target, so you can keep testing on it.
* It does not count toward a quota, and a full quota does not stop it.
* It cannot be removed. Use an opportunity you keep for testing.

### 2. Choose what happens [#2-choose-what-happens]

Both fields are optional.

| Field               | Value                 | What the test participant does                                                                                              |
| ------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `screening_outcome` | `passed` (default)    | Gets through the screener.                                                                                                  |
|                     | `failed`              | Answers the screener and is screened out.                                                                                   |
|                     | `filtered_out`        | Is screened out on its profile before the screener. Allowed even when the opportunity has no filters.                       |
| `task_completion`   | `automatic` (default) | Terac finishes the task.                                                                                                    |
|                     | `redirect`            | Terac starts the task and stops. You open `task_url` from the response, and your own completion redirect finishes the task. |

The screening answers are real answers to your screening questions, graded the way a
participant's are. What the submission shows afterwards depends on what you asked for:

| You asked for  | `screening_outcome` on the submission                                         | `screening_answers`                            |
| -------------- | ----------------------------------------------------------------------------- | ---------------------------------------------- |
| `passed`       | `passed`, or `review` when the only answers that get through are flagged ones | The answers the participant gave.              |
| `failed`       | `failed`                                                                      | The answers the participant gave.              |
| `filtered_out` | `null`                                                                        | Empty: the participant never saw the screener. |

An opportunity with no screener also gives `null` and no answers. A participant whose answers
are flagged for review waits in your applicant queue, whatever `customer_screening_review` is
set to.

`GET /submissions/{submissionId}` returns `404` while the participant is still in screening,
which includes waiting in your applicant queue. Read a waiting applicant from
`GET /opportunities/{opportunityId}/applicants`.

### 3. What your endpoint receives [#3-what-your-endpoint-receives]

When the participant passes the screener and you review the work, which is the default:

| Step                                                | `from` → `to`                                              |
| --------------------------------------------------- | ---------------------------------------------------------- |
| The participant passes the screener                 | `screening` → `screen_passed`                              |
| It starts the task                                  | `screen_passed` → `in_progress`                            |
| It finishes the task                                | `in_progress` → `awaiting_review`                          |
| You call `POST /submissions/{submissionId}/approve` | `awaiting_review` → `approved`, plus `submission.approved` |
| or `POST /submissions/{submissionId}/reject`        | `awaiting_review` → `rejected`                             |

Other setups and choices:

| Situation                                                                                  | What you receive                                                                                                                                                                                                                                                                                                              |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The task approves itself on completion: `auto_approve` with a `task_url`, or `self_report` | The first two rows above, then `in_progress` → `approved`, plus `submission.approved`.                                                                                                                                                                                                                                        |
| The opportunity has no screener                                                            | The first webhook is `init` → `in_progress`. There is no `screen_passed`.                                                                                                                                                                                                                                                     |
| The opportunity has no tasks                                                               | `screening` → `screen_passed`, and nothing after it.                                                                                                                                                                                                                                                                          |
| You review applicants first: `customer_screening_review` is `manual_review`                | Nothing until you act. Joining your applicant queue sends no webhook, so poll `GET /opportunities/{opportunityId}/applicants`. `POST /submissions/{submissionId}/invite` sends `screening` → `screen_passed`, and the steps continue as above. `POST /submissions/{submissionId}/decline` sends `screening` → `screened_out`. |
| `screening_outcome` is `failed`                                                            | `screening` → `screened_out`                                                                                                                                                                                                                                                                                                  |
| `screening_outcome` is `filtered_out`                                                      | `init` → `screened_out`                                                                                                                                                                                                                                                                                                       |
| `task_completion` is `redirect`                                                            | The participant stops after `screen_passed` → `in_progress`. Your redirect to the completion callback sends the next one: `in_progress` → `awaiting_review`, or `in_progress` → `approved` when the task approves itself.                                                                                                     |

### Timing [#timing]

The participant takes its first step a few seconds after your request and the next ones about
three seconds apart. Each webhook is queued when its step happens and delivered shortly
afterwards, so allow about a minute for the three webhooks of the default flow.

The participant stops wherever the next move is yours: inviting or declining an applicant,
approving or rejecting the work, or your own completion redirect. Your call sends the next
webhook, as it does on a live opportunity.

After you invite an applicant, the participant still has the task to do. Terac looks for your
decision every 30 seconds during the first hour after you created the test submission, and
every 5 minutes after that, and the participant carries on from the next look. A test
submission that is more than 24 hours old no longer moves by itself. It stays where it is, and
you can create a new one.

Pausing the opportunity holds a test participant where it is, as it holds a real one. It
carries on after you resume, on the same schedule. Stopping the opportunity ends it: the
participant takes no further steps.

### Finishing the task yourself [#finishing-the-task-yourself]

With `task_completion` set to `redirect`, the response carries `task_url`: the opportunity's
task link with the test submission's IDs filled in, exactly as a participant receives it. Wait
for the `screen_passed` → `in_progress` webhook, then open the link. When your tool redirects
to Terac's completion callback, the task is finished and the next webhook is sent.

A redirect that arrives before the task has started does not finish it. Send it again once the
`screen_passed` → `in_progress` webhook has arrived.

After the callback your browser lands on Terac's sign-in page, because nobody is signed in as
the test participant. The task is finished all the same. See
[External Surveys & Redirect URLs](https://terac.com/docs/researchers/integrations/external-surveys) for the
callback and its `result` values.

### When the request is refused [#when-the-request-is-refused]

The `message` says which case applies.

| Status | When                                                                                                                                                                                                                                                                                                        |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `403`  | The API key does not belong to a sandbox organization.                                                                                                                                                                                                                                                      |
| `409`  | The opportunity is not active. The `message` names the status it is in.                                                                                                                                                                                                                                     |
| `422`  | The opportunity cannot produce what you asked for: `failed` with no screener, or with screening questions that no answer fails; `passed` with screening questions that no answer passes, or with no screener and no tasks; `redirect` when the first task has no `task_url`, or has one that is not a link. |
| `422`  | Terac could not find screening answers that give the outcome you asked for. It tries a limited number of combinations, so on a very large screener some may exist that it did not reach. Ask for a different `screening_outcome`.                                                                           |

## Retries [#retries]

A delivery that does not return `2xx` is retried **12 times** after the first attempt, with
the first retry a minute later and exponential backoff up to 12 hours: about two and a half
days of trying in total.

`5xx`, `408` and `429` are retried. Any other `4xx` is treated as a deliberate rejection and
not retried, since a dozen identical requests would be refused identically. `410 Gone` is the
clearest way to say "stop".

Redirects are never followed. The signature is bound to your host, so a `3xx` elsewhere would
hand it to whoever controls that host. Point `target_url` at the final URL.

Deliveries time out after 10 seconds. Acknowledge with a `2xx` first and do your work
afterwards, rather than holding the connection open.

## When a subscription stops [#when-a-subscription-stops]

A subscription that fails **100% of the time for five consecutive days** is disabled
automatically. Someone from Terac reaches out on day three first.

A subscription disabled this way comes back with `is_enabled: false` and a
`disabled_reason`, which is how you tell it apart from one you turned off yourself. Re-enable
it once the endpoint is healthy:

```bash
curl -X PATCH https://terac.com/api/external/v2/hooks/subscriptions/whs_123 \
  -H "Authorization: Bearer $TERAC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"is_enabled": true}'
```

Changing `target_url` clears the confirmation, since the new host has not accepted a ping
yet. Confirm again to resume. Changing `event_types` does not: that host has already accepted
one.

## The delivery log [#the-delivery-log]

`GET /hooks/events` lists deliveries, newest first, with `?subscription_id=` to narrow and
cursor pagination. One row per delivery, updated in place across retries, so `id` is the
`X-Event-ID` your endpoint saw and `attempt_count` is how many tries it took.

Confirmation pings are not logged there: they are a handshake, not a delivery.

## Handling duplicates [#handling-duplicates]

Deduplicate on `X-Event-ID`. It is stable across retries, so the same value arriving twice
means you already have that event and can answer `2xx` without reprocessing. Terac will not
deliver a payload that has already been acknowledged, but a network failure after your
endpoint committed and before the response reached us looks identical to a failure from our
side, and that is the case the header exists for.