Preview · under active developmentv2

This documentation describes the upcoming Transcrypts SDK and is shared early so teams can plan their integration. Endpoints, payloads, and SDK methods may change before general availability. We'll publish a changelog and email integrators ahead of any breaking change. For early-access questions, reach us at support@transcrypts.com.

Overview

Introduction

Transcrypts is an Employment and Income Verification platform. Embed our widget in your app, your users authorize verification through their preferred source (Social Security Administration, IRS, payroll provider, bank, or employer), and you receive verified results on your server via signed webhooks.

This page covers everything you need to integrate Transcrypts into your app. Before you can ship, you'll need an organization account. See Get Access below.

Questions? Email support@transcrypts.com.


How It Works

Verification flows through four steps. Your server is always the source of truth for verified data. The widget is a UI surface, not a data channel.

1

Your server creates a verification session

Call POST /v1/verification_sessions with your secret key. You receive a short-lived client_secret to pass to your frontend.

2

Your frontend opens the widget

Initialize the SDK with your publishable key and call verify({ clientSecret }). The widget opens in an iframe, the user authenticates with their data source, and views their verified result.

3

Your server receives a signed webhook

When verification completes, Transcrypts POSTs a signed event to your registered webhook endpoint. Verify the signature, then save the result to your database.

4

Optionally retrieve the session anytime

Call GET /v1/verification_sessions/:id with your secret key to fetch the canonical record.

Webhook is the source of truth
The widget sends a UI completion ping to your frontend, but it does not include verified data. That keeps PII out of the browser. Always use the webhook (or the GET endpoint) to populate your database.

Get Access

Transcrypts is currently onboarding integrators by request. Email sales@transcrypts.com with your company name, expected monthly verification volume, and which verification rails you need (SSA, IRS, payroll, bank, or all). We'll provision an organization account and send you dashboard credentials.

Dashboard

Once your account is active, sign in at app.transcrypts.com to self-manage everything:

SectionWhat you can do
API KeysCreate publishable and secret keys, set allowed origins, attach webhook endpoints, revoke keys.
WebhooksRegister endpoint URLs, view their signing secrets, filter by event type, send test events, inspect recent deliveries.
Widget SettingsEnable or disable individual verification rails (SSA, IRS, payroll, bank). Customize widget branding (logo, display name, primary color).
VerificationsBrowse the history of verifications run against your account.

Sandbox

Build against sandbox before flipping to production. Sandbox runs the same API surface against a fully isolated environment: its own keys, its own webhook endpoints, mocked verification backends, and deterministic outcomes driven by magic credentials.

EnvironmentDashboardAPI base URL
Sandboxsandbox.transcrypts.comhttps://sandbox.transcrypts.com
Productionapp.transcrypts.comhttps://app.transcrypts.com

Sign in to each dashboard separately. Sandbox keys (pk_live_*, sk_live_*) only authenticate against sandbox; production keys only authenticate against production. Promoting from sandbox to production is two env-var swaps in your app (the keys and the base URL), with no application-code changes.

Environment field
Every webhook payload includes a top-level environment string ("sandbox" or "production"). If you share one webhook handler across both, branch on this to route to the right database.

Magic credentials: “Verify with Transcrypts”

In sandbox, the SSN entered in the widget drives the outcome. Other fields (name, company) are ignored. Every run also fires verification_session.created (on server-side create) and verification_session.processing (when the rail starts) before the terminal event below.

SSNTerminal webhook eventNotes
000-00-0001verification_session.verifiedresult_data has 2 employers spanning 2020–present with year-by-year earnings.
000-00-0002verification_session.failedfailure_reason.code = "no_match".
Any other SSNverification_session.failedfailure_reason.code = "no_match" (treated as no record).

Magic credentials: “Verify with IRS”

For the IRS rail in sandbox: any email and password are accepted. The rail always moves to the OTP screen, and the OTP code drives the outcome. Every run also fires verification_session.created and verification_session.processing before the terminal event below.

OTPTerminal webhook eventNotes
123456verification_session.verifiedHappy path. Full SSE stage sequence; result_data has 2 employers + 3 years of W-2 data.
999999verification_session.verifiedSame as 123456 with 10× delays. Use to test the long-running user-waits UX.
000000verification_session.failedOTP accepted, ~10s processing, then permanent failure. failure_reason.code = "post_otp_failure".
anything else(none, transient)Rejected as invalid with retry hint. Session stays in processing, no terminal webhook fires. Submit 123456 on the next attempt to recover. Use this to test mistype-and-recover.
Fully mocked
In sandbox the IRS rail is fully mocked. No actual login to IRS.gov happens. Email and password fields exist to mirror the real flow but neither value affects the outcome.

Quick Start

A complete integration takes three pieces of code: one server-side request to create a session, one frontend call to open the widget, and one webhook handler to receive the result.

1. Install the SDK

One package for the frontend (opens the widget). Server-side calls are plain REST. No separate package needed.

bash
# Browser SDK for opening the widget in your frontend.
# Server-side calls go directly to the REST API. No separate package.
npm install transcrypts
# or
yarn add transcrypts
# or
pnpm add transcrypts

2. Create a session on your server

Use your secret key (sk_live_*) to create a session. Never expose this key in the browser.

typescript
// On your server
const response = await fetch(
  `${process.env.TRANSCRYPTS_BASE_URL}/api/sdk/v1/verification_sessions`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.TRANSCRYPTS_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      applicant_hint: {
        firstName: 'John',
        lastName: 'Doe',
        email: 'john@example.com',
      },
      metadata: { your_internal_id: 'LN_12345' },
      return_url: 'https://yourapp.com/verified',
    }),
  }
);

const session = await response.json();
// Send session.client_secret to your frontend.
// { id, client_secret, expires_at, status, ... }

3. Open the widget on your frontend

Pass the client_secret from step 2 to your frontend, then open the widget using your publishable key (pk_live_*).

typescript
// In your frontend
import { Transcrypts } from 'transcrypts';

const transcrypts = new Transcrypts({
  publishableKey: 'pk_live_your_publishable_key',
  // Drop or set to 'production' when you go live.
  mode: 'sandbox',
});

const result = await transcrypts.verify({
  clientSecret: 'cs_secret_from_your_server',
});

// result contains UI signals only, no PII.
// { sessionId, status: 'completed' | 'pending' | 'failed' }
console.log(result.sessionId, result.status);

4. Receive the result on your server

Register a webhook endpoint in the dashboard. When verification completes, Transcrypts sends a signed POST request. Verify the signature, then write the result to your database.

typescript
// On your server
import crypto from 'crypto';

app.post(
  '/webhooks/transcrypts',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-transcrypts-signature'];
    const secret = process.env.TRANSCRYPTS_WEBHOOK_SECRET;
    const body = req.body.toString('utf8');

    // Parse "t=<timestamp>,v1=<hex>" header
    const parts = Object.fromEntries(
      signature.split(',').map((p) => p.split('='))
    );

    // Reject stale events (>5 min) as a replay-attack defense.
    const tolerance = 5 * 60;
    if (Math.abs(Date.now() / 1000 - parseInt(parts.t, 10)) > tolerance) {
      return res.status(401).end();
    }

    // Recompute the HMAC and compare in constant time.
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${parts.t}.${body}`)
      .digest('hex');

    if (
      !crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(parts.v1)
      )
    ) {
      return res.status(401).end();
    }

    const event = JSON.parse(body);

    if (event.type === 'verification_session.verified') {
      // event.environment is 'sandbox' or 'production'. Branch on it if you
      // share one handler across both deployments.
      // event.data.result_data has the normalized subject + employers + earnings.
    }

    res.json({ received: true });
  }
);

Authentication

Transcrypts uses four key types. Each has a distinct prefix and purpose. All keys are generated and revoked from the dashboard.

PrefixTypeUsed where
pk_live_*PublishableFrontend SDK init. Safe to embed in browser code. Authenticates GET /widget-config (org-level branding + enabled rails). Origin-restricted to your registered domains.
sk_live_*SecretServer-side API calls (create / retrieve verification sessions). Never expose in browser.
cs_*Client secretShort-lived, single-use, scoped to one verification session. Generated by the server (POST /verification_sessions), passed to the frontend, consumed by the widget for the actual verification work.
whsec_*Webhook secretOne per webhook endpoint. Used to verify signature on incoming events. Never expose.

Origin Restrictions

Each publishable key carries an allowed-origins list set in the dashboard. Requests from origins not on the list are rejected at the CORS layer. Add every domain that will embed the widget (e.g. https://app.yourcompany.com).

Warning
Secret keys, webhook secrets, and client secrets must never appear in client-side code, public repos, or logs. The publishable key is the only key safe to put in your frontend.

Server API

Two endpoints, both server-side. Send your secret key as Authorization: Bearer sk_live_*. Examples use Node's built-in fetch; any HTTP client works.

Create a verification session

POST /api/sdk/v1/verification_sessions

typescript
// On your server
const response = await fetch(
  `${process.env.TRANSCRYPTS_BASE_URL}/api/sdk/v1/verification_sessions`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.TRANSCRYPTS_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      applicant_hint: {
        firstName: 'John',
        lastName: 'Doe',
        email: 'john@example.com',
      },
      metadata: { your_internal_id: 'LN_12345' },
      return_url: 'https://yourapp.com/verified',
    }),
  }
);

const session = await response.json();
// Send session.client_secret to your frontend.
// { id, client_secret, expires_at, status, ... }
FieldTypeRequiredDescription
applicant_hintobject
Optional
Pre-fills the personal-info step that appears when the user picks "Verify with Transcrypts". Other rails (IRS, etc.) authenticate the user directly and ignore this hint. Fields: firstName, lastName, email.
metadataobject
Optional
Arbitrary JSON, echoed back unchanged in every webhook event and in GET /verification_sessions/:id. See the round-trip example below.
return_urlstring
Optional
URL the user is sent to after closing the widget. Used by redirect-based rails.
json
{
  "id": "vs_01H8X7B3K5Z6Y2W9V4Q1N8M7L0",
  "client_secret": "cs_live_zR3vP9...xK1qLp",
  "status": "requires_input",
  "expires_at": "2026-05-20T19:14:00Z"
}
FieldTypeDescription
idstringUnique session identifier, prefixed vs_. Echoed on every webhook event and retrievable via GET /verification_sessions/:id.
client_secretstringSingle-use credential prefixed cs_. Pass this to your frontend; the widget authenticates with it. Do not log or persist.
statusenumInitial state, always "requires_input" at creation. See the Status enum below.
expires_atstringISO 8601 timestamp. Defaults to 30 minutes after creation. After this point the client_secret will not authenticate.

Correlating sessions with your own users

Pass your own internal IDs in metadata when you create the session. Transcrypts stores it as opaque JSON and echoes it back unchanged in every webhook event and on GET /verification_sessions/:id. Your webhook handler reads the metadata to look up the corresponding user/order/loan in your own database.

typescript
// 1. When the user clicks &quot;Verify employment&quot; in your app:
const session = await fetch(`${BASE_URL}/api/sdk/v1/verification_sessions`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${SK}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    applicant_hint: { firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
    metadata: {
      your_user_id: 'user_abc123',   // ← whatever IDs you need
      your_loan_id: 'LN_42',
    },
  }),
}).then(r => r.json());

// 2. Later, in your webhook handler:
if (event.type === 'verification_session.verified') {
  const userId = event.data.metadata.your_user_id;   // 'user_abc123'
  const loanId = event.data.metadata.your_loan_id;   // 'LN_42'

  await db.users.update({
    where: { id: userId },
    data: { employment: event.data.result_data },
  });
}
Tip
Metadata is preserved exactly as you sent it: same keys, same types, no normalization. Use it for any correlation strategy that fits your app. The session id (returned at creation) also round-trips on every event if you prefer storing that as a foreign key on your side.

Retrieve a verification session

GET /api/sdk/v1/verification_sessions/:id

typescript
// On your server
const response = await fetch(
  `${process.env.TRANSCRYPTS_BASE_URL}/api/sdk/v1/verification_sessions/${sessionId}`,
  {
    headers: {
      Authorization: `Bearer ${process.env.TRANSCRYPTS_SECRET_KEY}`,
    },
  }
);

const session = await response.json();
// {
//   id, status, vendor, applicant_hint, result_data,
//   failure_reason, metadata, created_at, updated_at, expires_at
// }

Useful for backfilling missed webhooks or building admin views. The response is the canonical session shape · the same object delivered as event.data in every webhook event for this session.

json
{
  "id": "vs_01H8X7B3K5Z6Y2W9V4Q1N8M7L0",
  "status": "verified",
  "vendor": "irs",
  "applicant_hint": {
    "firstName": "John",
    "lastName": "Doe",
    "email": "john@example.com"
  },
  "result_data": {
    "subject": {
      "name": "JOHN DOE",
      "address": "123 MAIN ST
ANYTOWN CA 94016",
      "ssn": "XXX-XX-1234"
    },
    "employers": [
      {
        "name": "Acme Corp",
        "jobTitle": null,
        "startDate": null,
        "endDate": null,
        "startYear": 2023,
        "endYear": 2024,
        "employmentStatus": "unknown",
        "source": "irs"
      }
    ],
    "earnings": [
      {
        "year": 2024,
        "employerName": "Acme Corp",
        "employerEin": "12-3456789",
        "totalWages": 95500
      }
    ]
  },
  "failure_reason": null,
  "metadata": { "your_internal_id": "LN_12345" },
  "return_url": "https://yourapp.com/verified",
  "created_at": "2026-05-20T18:14:00Z",
  "updated_at": "2026-05-20T18:24:11Z",
  "expires_at": "2026-05-20T18:44:00Z"
}
FieldTypeDescription
idstringSession identifier, prefixed vs_.
statusenumCurrent lifecycle state. One of requires_input, processing, verified, failed, canceled, expired.
vendorenum | nullVerification rail the user chose. One of transcrypts, irs. Null until the widget picks one.
applicant_hintobject | nullPre-fill hints echoed back from session creation. See the applicant_hint schema below.
result_dataobject | nullNormalized verification result. Populated only when status is verified. See the result_data schema below.
failure_reasonobject | nullPopulated only when status is failed. See the failure_reason schema below.
metadataobjectArbitrary JSON you supplied at creation, echoed back unchanged.
return_urlstring | nullURL the user is sent to after closing the widget, if you supplied one.
created_atstringISO 8601 timestamp when the session was created.
updated_atstringISO 8601 timestamp of the last status transition.
expires_atstringISO 8601 timestamp after which the client_secret stops authenticating.
FieldTypeDescription
requires_inputstringSession created. Awaiting the user to pick a rail and complete it in the widget.
processingstringUser has started a rail. Async backend work is in progress. Some rails go straight from requires_input to verified and skip this state.
verifiedstringVerification succeeded. result_data is populated. Terminal state.
failedstringVerification could not be completed. failure_reason is populated. Terminal state.
canceledstringUser closed the widget without finishing. Only reached from requires_input or processing. Terminal state.
expiredstringclient_secret aged out before the user completed verification. Terminal state.

Only the "Verify with Transcrypts" rail surfaces a personal-info form, and that is the only rail these hints pre-fill. The IRS rail (and any future account-linking rail) authenticates the user directly and never asks for these fields.

FieldTypeDescription
firstNamestringOptional. Pre-fills the First Name field in the Transcrypts rail form.
lastNamestringOptional. Pre-fills the Last Name field in the Transcrypts rail form.
emailstringOptional. Pre-fills the Email field in the Transcrypts rail form.

Shape is identical across rails · integrators write one mapper, not one per rail. The source field on each employer tells you which rail produced it.

FieldTypeDescription
subjectobject | nullPersonal information identified by the rail itself (not self-reported). Populated by the IRS rail from the authenticated Wage & Income Transcript. Null on the Transcrypts rail. See the subject schema below.
employersarray<Employer>Employment records returned by the rail. May be empty if the rail returned no matches.
earningsarray<Earning>Year-by-year wage records. Populated by the IRS rail (one row per year per employer). Empty for the Transcrypts rail.
Subject

Authenticated identity claim sourced directly from the rail's data provider — not from applicant_hint or any user-typed field. The IRS rail populates this from the personal information section of the Wage & Income Transcript; the Transcrypts rail leaves it null. Use it to cross-check that the verified person matches the applicant you expected.

FieldTypeDescription
namestring | nullFull legal name as recorded by the data source. IRS returns it as "LAST FIRST MIDDLE" (uppercased).
addressstring | nullMailing address as recorded by the data source. May contain newline characters between street and city/state/ZIP.
ssnstring | nullAlways masked — only the last 4 digits are returned (e.g. "XXX-XX-1234"). IRS Wage & Income Transcripts are redacted at the source; full SSN is never exposed.
Employer
FieldTypeDescription
namestringEmployer name.
jobTitlestring | nullJob title at this employer, when the rail provides it. Null on the IRS rail (W-2 transcripts do not carry job title).
startDatestring | nullReal ISO hire date when the rail has one. Null on year-only rails such as IRS (W-2 transcripts carry no employment dates) — see startYear.
endDatestring | nullReal ISO end date. On date-precise rails, null means current employment. Always null on IRS, which has no end date — see endYear.
startYearnumber | nullEarliest year of employment. On IRS, the earliest tax year with reported income.
endYearnumber | nullLatest year of employment. On IRS, the latest tax year with reported income — not a termination; check employmentStatus.
employmentStatusenum | nullOne of active, terminated, unknown.
sourceenumWhich rail produced this record. One of transcrypts, irs.
confidenceenumOptional. One of high, medium, low. Populated by the Transcrypts rail; omitted on IRS.
Earning
FieldTypeDescription
yearnumberTax year.
employerNamestring | nullEmployer name from the W-2.
employerEinstring | nullEmployer EIN from the W-2, formatted XX-XXXXXXX.
totalWagesnumberWages, tips, and other compensation in dollars (W-2 box 1).
FieldTypeDescription
codestringMachine-readable code. Examples: no_match, invalid_otp, auth_failed, post_otp_failure.
messagestringHuman-readable explanation, safe to surface in your own UI.

Client SDK

The frontend SDK is a small TypeScript package (~5KB) that opens the widget in an iframe overlay. It is framework-agnostic and works with React, Vue, Angular, or vanilla JavaScript.

Initialization

typescript
const transcrypts = new Transcrypts({
  // Required: Your publishable key from the dashboard
  publishableKey: 'pk_live_xxx',

  // Optional: 'sandbox' while building, omit (or 'production') when live.
  mode: 'sandbox',

  // Optional: Custom container styles for the overlay
  containerStyle: {
    backgroundColor: 'rgba(0, 0, 0, 0.8)',
  },
});
OptionTypeRequiredDescription
publishableKeystring
Required
Your publishable key (pk_live_*).
mode'sandbox' | 'production'
Optional
Which Transcrypts environment to target. Defaults to "production". Use "sandbox" while building against magic credentials; flip to "production" (or remove the option) when you ship.
apiBaseUrlstring
Optional
Explicit base URL. Overrides "mode" when set. Use for local development ("http://localhost:3000") or custom domains. Most integrators do not need this.
containerStyleCSSStyleDeclaration
Optional
Custom styles for the overlay container.

Opening the Widget

verify({ clientSecret }) opens the widget and returns a promise that resolves when the user closes it.

typescript
// In your frontend
import { Transcrypts } from 'transcrypts';

const transcrypts = new Transcrypts({
  publishableKey: 'pk_live_your_publishable_key',
  // Drop or set to 'production' when you go live.
  mode: 'sandbox',
});

const result = await transcrypts.verify({
  clientSecret: 'cs_secret_from_your_server',
});

// result contains UI signals only, no PII.
// { sessionId, status: 'completed' | 'pending' | 'failed' }
console.log(result.sessionId, result.status);
Note
The returned result is a UI signal only: { sessionId, status }. It never contains PII or verified data. To read the verified result, handle the webhook on your server (or call the retrieve endpoint).

Widget Events

Subscribe to widget lifecycle events. Useful for analytics, spinners, or updating UI state.

typescript
transcrypts.on('ready', () => {
  console.log('Widget loaded');
});

transcrypts.on('verification_session_completed', (event) => {
  // UI signal only. Trust the webhook for data.
  console.log(event.payload.sessionId, event.payload.status);
});

transcrypts.on('close', () => {
  console.log('User closed the widget');
});
EventPayloadDescription
ready{}Widget iframe loaded and ready for user interaction.
verification_session_completed{ sessionId, status }User finished the flow. Status is one of: completed, pending, failed.
close{}User dismissed the widget without completing.

Method Reference

verify(options)Promise<VerificationResult>

Opens the widget with a client_secret from your server. Resolves when the user closes the widget. Returns { sessionId, status } only.

on(eventType, callback)() => void

Subscribe to widget events. Returns an unsubscribe function.


Webhooks

Webhooks are the canonical channel for delivering verified data to your server. Configure one or more endpoints in the dashboard; each endpoint can be attached to one or more API keys. Events fire only to endpoints attached to the key that created the originating session.

Configure an endpoint

In the dashboard, go to Webhooks → Create endpoint. Provide:

  • URL: an HTTPS endpoint on your server that accepts POST requests.
  • Event types: choose which events to receive, or leave as * for all.
  • Attached secret keys: webhooks fire based on which secret key created the session, so endpoints can only be attached to sk_live_* keys (publishable keys can't create sessions). Attaching to multiple secret keys fans out to the same endpoint when any of them creates a session.

After creation, the dashboard shows the signing secret (whsec_*) once. Save it; you'll need it to verify signatures.

Event Types

EventFired when
verification_session.createdA session was created via POST /v1/verification_sessions.
verification_session.processingThe user picked a rail and submitted their input. Verification work has started; final outcome will arrive as verified or failed.
verification_session.verifiedVerification succeeded. data.result_data contains the normalized employers + earnings.
verification_session.failedVerification could not be completed. data.failure_reason contains code + message.
verification_session.canceledUser closed the widget while the session was still pre-terminal (requires_input or processing). Does NOT fire if they close after verified/failed.
verification_session.expiredclient_secret aged out (30 min by default) before the user opened the widget.
webhook.testSent when you click "Send test event" in the dashboard.

Payload Schema

Every webhook event has the same envelope. The data field carries the canonical session object · the same shape returned by GET /verification_sessions/:id.

json
// POST https://yourapp.com/webhooks/transcrypts
// Headers:
//   X-Transcrypts-Signature: t=1731600000,v1=4b3a...
//   Content-Type: application/json

{
  "id": "evt_01H8X7...",
  "type": "verification_session.verified",
  "created_at": "2026-05-14T18:24:11Z",
  "environment": "production",
  "data": {
    "id": "vs_01H8X7...",
    "status": "verified",
    "vendor": "irs",
    "result_data": {
      "subject": {
        "name": "JOHN DOE",
        "address": "123 MAIN ST\nANYTOWN CA 94016",
        "ssn": "XXX-XX-1234"
      },
      "employers": [
        {
          "name": "Acme Corp",
          "jobTitle": null,
          "startDate": null,
          "endDate": null,
          "startYear": 2023,
          "endYear": 2024,
          "employmentStatus": "unknown",
          "source": "irs"
        }
      ],
      "earnings": [
        { "year": 2024, "employerName": "Acme Corp", "employerEin": "12-3456789", "totalWages": 95500 },
        { "year": 2023, "employerName": "Acme Corp", "employerEin": "12-3456789", "totalWages": 92100 }
      ]
    },
    "metadata": { "your_internal_id": "LN_12345" }
  }
}
FieldTypeDescription
idstringUnique event identifier, prefixed evt_. Use this for idempotency · safe to re-process the same id without side effects.
typeenumEvent type. See the Event Types table above for the full list.
created_atstringISO 8601 timestamp when the event was generated server-side.
environmentenumOne of sandbox, production. Lets a shared handler distinguish test traffic from real.
dataobjectThe canonical verification session object. See the response schema for GET /verification_sessions/:id for every field.
FieldTypeDescription
2xx statusresponseMarks the delivery as succeeded. Body is ignored · respond as soon as you have written the event id for idempotency. Aim for under 5s.
non-2xx or timeoutresponseTreated as a failure. The delivery is scheduled for retry per the backoff schedule below. After ~32h of failures the endpoint is auto-disabled.

Signature Verification

Every webhook includes an X-Transcrypts-Signature header in the format t=<timestamp>,v1=<hmac>. The HMAC is SHA-256 of <timestamp>.<raw_body> using your endpoint's signing secret.

typescript
// Node.js helper
function verifySignature(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map((p) => p.split('='))
  );

  const timestamp = parseInt(parts.t, 10);
  const tolerance = 5 * 60; // 5 minutes
  if (Math.abs(Date.now() / 1000 - timestamp) > tolerance) {
    return false;
  }

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

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(parts.v1)
  );
}
Warning
Always verify the signature and check that the timestamp is within 5 minutes of the current time. Reject events with invalid signatures or stale timestamps to protect against replay attacks.

Retries and Delivery

We treat any non-2xx response as a failure. Failed deliveries are retried with exponential backoff: immediately, +15s, +5m, +30m, +1h, +6h, +24h. After ~32 hours of continuous failure, the endpoint is auto-disabled and the organization owner is emailed.

Every delivery attempt, success or failure, is logged in the dashboard under Webhooks → [endpoint] → Deliveries. You can replay any past delivery manually from there.

Idempotency
Each event has a unique id. The same event may be delivered more than once (during retries). Use the event id to deduplicate on your side.

Verification Methods

The widget supports five verification rails. Users choose which rail to use when the widget opens. You can enable or disable rails per-org in Dashboard → Widget Settings.

Social Security Account

User signs into ssa.gov inside the widget. Returns year-by-year employer and earnings history from the SSA. Sync: result is available immediately when the user completes the flow.

IRS Transcripts

User signs into IRS.gov inside the widget. Returns wage and income transcripts (W-2 data) for recent tax years. Sync.

Payroll Integration

User connects their payroll provider (ADP, Gusto, Workday, and others). Returns paystubs and employer details. Sync.

Bank Integration

User links their bank account. Recurring direct deposit patterns are used to verify income and employer. Sync.

Employer Contact

We contact the user's employer or payroll department directly to confirm employment. Async: the widget shows a pending state, and your server receives a verification_session.processing event immediately, followed by verification_session.verified when the response comes back (typically within 1–3 business days).


Widget Branding

The widget can be co-branded with your logo and color so it feels native inside your app. Configure in Dashboard → Widget Settings → Branding.

SettingDescription
LogoReplaces the Transcrypts logo in the widget header. PNG or SVG, max 500KB.
Display nameReplaces "Transcrypts" in body copy. Used in headers and CTAs.
Primary colorHex color used for buttons, links, and accents.
Note
A “Powered by Transcrypts” line appears in the widget footer regardless of branding configuration.