Skip to main content
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 (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.

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.

Configuration

Every setting is an environment variable with a matching CLI flag:
Env varFlagUsed byValue
OPENTRAIN_BASE_URL--opentrain-urlallhttps://app.opentrain.ai
OPENTRAIN_PARTNER_TOKEN--partner-tokenlink, subscribe (optional for run)ot_ptk_… from the consent screen
OPENTRAIN_WEBHOOK_SECRET--webhook-secretrunwhsec_… from endpoint creation
TOOL_BASE_URL--tool-urllink, runyour tool’s API origin, e.g. http://localhost:8080
TOOL_API_TOKEN--tool-tokenlink, runtool-side admin API token

Step 1: Connect

The customer (an OpenTrain employer) opens your consent deep link, approves the scopes plus the PII consent checkbox, and pastes the one-time ot_ptk_… token into your configuration.
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, so contract events for that job route to this integration:
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

npx tsx opentrain-integration.ts subscribe --url https://your-host.example.com/webhooks/opentrain
This registers a webhook endpoint for contract.started, contract.ended, project_link.removed, and install.revoked, then prints the one-time whsec_… secret:
Export the secret before starting the listener:
  export OPENTRAIN_WEBHOOK_SECRET=whsec_<SECRET>

Step 4: Run the Listener

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:
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 (1m, 5m, 30m, 120m).

Handle Contract Events

contract.started finds or creates the workspace user by Work Email; contract.ended removes their organization membership:
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 — the same consent gates apply, but this makes the handler robust to redelivered or hand-replayed events:
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

FailureWhat happensWhat you do
Handler throws (e.g. your tool’s API briefly down)Listener returns 500; OpenTrain retries up to 5 attemptsUsually nothing — a later retry succeeds
Listener down past the retry windowDeliveries marked FAILED; 10 consecutive failures auto-disable the endpointPATCH {"status": "ACTIVE"}, then redeliver all FAILED
Events from before the subscription existedNever delivered — no backfillReconcile via GET /contracts?status=active

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.