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

# Reference Integration: Webhook Consumer

> A complete worked example: a single-file webhook consumer that provisions hired AI trainers into your annotation workspace and offboards them when contracts end.

This page walks through a complete, working platform integration: a single TypeScript file that links one of your annotation projects to an OpenTrain job, subscribes to lifecycle webhooks, and then provisions and offboards AI trainers automatically as contracts start and end. It was validated end-to-end against a self-hosted open-source annotation tool.

The integration touches only generic user/organization APIs on the tool side — find-or-create a user by email, remove an organization membership — so the same structure maps directly onto any annotation platform or internal tool with a members API.

## Architecture

The integration is a **pure webhook consumer**, platform-side only:

* It talks to OpenTrain exclusively through the [Platform API](/docs/developers/annotation-platforms/api-reference/overview) (`Authorization: Bearer ot_ptk_…`) and signed webhooks.
* Its only imports are `node:crypto` (signature verification) and `node:http` (the listener) — no SDKs, no frameworks.
* It has three subcommands: `link`, `subscribe`, and `run`.

```mermaid theme={null}
flowchart LR
    OT[OpenTrain] -- "signed webhook<br/>contract.started / contract.ended" --> C[Consumer<br/>node:http listener]
    C -- "verify HMAC, dedupe" --> H[Event handler]
    H -- "find-or-create user,<br/>remove org membership" --> TOOL[Your annotation tool's API]
    H -. "participants fallback<br/>(workEmail missing)" .-> OT
```

## Prerequisites

* An annotation tool (or workspace) whose API you administer.
* A tool-side API token with **administrator rights over organization membership**. In many tools, a plain member's token can read and create users but gets `403` when removing memberships — offboarding silently breaks. Test the removal path with your token before going live.
* An OpenTrain platform token with `project-links:write`, `contracts:read`, `participants:read`, `participants:email`, and `webhooks:manage` — and an install granted **with PII consent**, since provisioning keys off the AI trainer's [Work Email](/docs/developers/concepts/privacy-and-work-email).

## Configuration

Every setting is an environment variable with a matching CLI flag:

| Env var                    | Flag               | Used by                                  | Value                                                |
| -------------------------- | ------------------ | ---------------------------------------- | ---------------------------------------------------- |
| `OPENTRAIN_BASE_URL`       | `--opentrain-url`  | all                                      | `https://app.opentrain.ai`                           |
| `OPENTRAIN_PARTNER_TOKEN`  | `--partner-token`  | `link`, `subscribe` (optional for `run`) | `ot_ptk_…` from the consent screen                   |
| `OPENTRAIN_WEBHOOK_SECRET` | `--webhook-secret` | `run`                                    | `whsec_…` from endpoint creation                     |
| `TOOL_BASE_URL`            | `--tool-url`       | `link`, `run`                            | your tool's API origin, e.g. `http://localhost:8080` |
| `TOOL_API_TOKEN`           | `--tool-token`     | `link`, `run`                            | tool-side admin API token                            |

## Step 1: Connect

The customer (an OpenTrain employer) opens your [consent deep link](/docs/developers/annotation-platforms/consent-and-installs), approves the scopes plus the PII consent checkbox, and pastes the one-time `ot_ptk_…` token into your configuration.

## Step 2: Link a Project

```bash theme={null}
npx tsx opentrain-integration.ts link --project 1 --job-id <OPENTRAIN_JOB_ID>
```

The `link` command reads the project from your tool's API and registers it as an OpenTrain [project link](/docs/developers/annotation-platforms/project-links), so contract events for that job route to this integration:

```typescript theme={null}
const { status, json } = await toolRequest(config, 'GET', `/api/projects/${projectId}/`);
const project = json as { id: number; title?: string };

await opentrainRequest(config, 'POST', '/api/partner/v1/project-links', {
  externalProjectId: String(project.id),
  externalProjectName: project.title ?? `Annotation project ${project.id}`,
  externalProjectUrl: `${config.toolBaseUrl}/projects/${project.id}`,
  provisioningMode: 'PARTNER_WEBHOOK',
  jobId,
});
```

## Step 3: Subscribe

```bash theme={null}
npx tsx opentrain-integration.ts subscribe --url https://your-host.example.com/webhooks/opentrain
```

This registers a [webhook endpoint](/docs/developers/annotation-platforms/webhooks) for `contract.started`, `contract.ended`, `project_link.removed`, and `install.revoked`, then prints the one-time `whsec_…` secret:

```text theme={null}
Export the secret before starting the listener:
  export OPENTRAIN_WEBHOOK_SECRET=whsec_<SECRET>
```

## Step 4: Run the Listener

```bash theme={null}
npx tsx opentrain-integration.ts run --port 8484 --path /webhooks/opentrain
```

The listener does four things in order on every delivery: verify, dedupe, handle, acknowledge.

### Verify the Signature

Raw bytes, constant-time compare, 300-second drift window — rejected deliveries get `401`:

```typescript theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

const SIGNATURE_TOLERANCE_SECONDS = 300;

function verifySignature(secret: string, signatureHeader: string, body: string): boolean {
  const parts = new Map(
    signatureHeader.split(',').map((part) => {
      const eq = part.indexOf('=');
      return [part.slice(0, eq), part.slice(eq + 1)] as const;
    })
  );
  const timestamp = parts.get('t');
  const signature = parts.get('v1');
  if (!timestamp || !signature) return false;

  const timestampSeconds = Number(timestamp);
  if (!Number.isFinite(timestampSeconds)) return false;
  const drift = Math.abs(Math.floor(Date.now() / 1000) - timestampSeconds);
  if (drift > SIGNATURE_TOLERANCE_SECONDS) return false;

  const expected = createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex');
  const expectedBuffer = Buffer.from(expected, 'utf8');
  const actualBuffer = Buffer.from(signature, 'utf8');
  if (expectedBuffer.length !== actualBuffer.length) return false;
  return timingSafeEqual(expectedBuffer, actualBuffer);
}
```

### Dedupe and Acknowledge

Retries reuse the same `X-OpenTrain-Delivery` ID, so the listener keeps a bounded set of processed IDs and answers `200 duplicate` for repeats. Successful handling returns `200`; a thrown error returns `500`, which makes OpenTrain [retry with backoff](/docs/developers/annotation-platforms/webhooks#retries) (1m, 5m, 30m, 120m).

### Handle Contract Events

`contract.started` finds or creates the workspace user by Work Email; `contract.ended` removes their organization membership:

```typescript theme={null}
async function handleEvent(config: Config, event: WebhookEvent): Promise<void> {
  switch (event.type) {
    case 'contract.started': {
      const resolved = await resolveWorkEmail(config, event);
      if (!resolved) return; // no consent/scope — surface the gap instead of guessing
      const { user, created } = await ensureWorkspaceUser(
        config, resolved.email, resolved.displayName
      );
      log(created ? 'provisioned workspace user' : 'user already exists', {
        userId: user.id,
        contractId: event.data?.contract?.id ?? event.resourceId,
      });
      return;
    }
    case 'contract.ended': {
      const resolved = await resolveWorkEmail(config, event);
      if (!resolved) return;
      const outcome = await removeWorkspaceOrgMember(config, resolved.email);
      log(`deprovision outcome: ${outcome}`); // removed | not_found | cannot_remove_self
      return;
    }
    case 'project_link.removed':
    case 'install.revoked':
      log(`received ${event.type} — stopping work for this link/install is up to the platform`);
      return;
  }
}
```

### Fall Back to the Participants Endpoint

If the payload lacks `workEmail` (or arrived before consent was sorted out), the handler pulls [`GET /contracts/{contractId}/participants`](/docs/developers/annotation-platforms/api-reference/contracts/participants) — the same consent gates apply, but this makes the handler robust to redelivered or hand-replayed events:

```typescript theme={null}
async function resolveWorkEmail(config: Config, event: WebhookEvent) {
  const freelancer = event.data?.freelancer;
  if (freelancer?.workEmail) {
    return { email: freelancer.workEmail, displayName: freelancer.displayName };
  }

  const contractId = event.data?.contract?.id ?? event.resourceId;
  if (!contractId || !config.partnerToken) return null;

  const result = await opentrainRequest(
    config, 'GET', `/api/partner/v1/contracts/${contractId}/participants`
  );
  const participants = (result.participants ?? []) as Array<{
    displayName?: string; workEmail?: string;
  }>;
  const match = participants.find((participant) => participant.workEmail);
  return match?.workEmail
    ? { email: match.workEmail, displayName: match.displayName ?? 'OpenTrain Freelancer' }
    : null;
}
```

## Failure Handling

| Failure                                            | What happens                                                                                                                                         | What you do                                                                                                                             |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Handler throws (e.g. your tool's API briefly down) | Listener returns `500`; OpenTrain retries up to 5 attempts                                                                                           | Usually nothing — a later retry succeeds                                                                                                |
| Listener down past the retry window                | Deliveries marked `FAILED`; 10 consecutive failures [auto-disable](/docs/developers/annotation-platforms/webhooks#auto-disable-and-recovery) the endpoint | `PATCH {"status": "ACTIVE"}`, then [redeliver](/docs/developers/annotation-platforms/api-reference/webhook-endpoints/redeliver) all `FAILED` |
| Events from before the subscription existed        | Never delivered — no backfill                                                                                                                        | Reconcile via [`GET /contracts?status=active`](/docs/developers/annotation-platforms/api-reference/contracts/list)                           |

## Tool-Side Gotchas

These came up validating the consumer against a real self-hosted tool — check the equivalents in yours:

* **User-list response shapes vary across versions.** Some builds return a plain array, others `{ "results": [...] }`. Handle both when searching for a user by email.
* **"Cannot remove self" surfaces oddly.** The tool refused to remove the token owner from their own organization with a `405` rather than a clear error — treat unexpected statuses on the removal path as a distinct outcome, not a generic failure.
* **Use an organization admin's token.** Membership removal returned `403` for plain members, which breaks offboarding while provisioning appears to work.
* **Check which auth scheme your token uses.** Some tools ship multiple token systems (e.g. a legacy `Authorization: Token <key>` header that must be explicitly enabled) — verify the scheme your integration uses is switched on.
