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.
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.
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.
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.
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.
Optionally retrieve the session anytime
Call GET /v1/verification_sessions/:id with your secret key to fetch the canonical record.
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:
| Section | What you can do |
|---|---|
| API Keys | Create publishable and secret keys, set allowed origins, attach webhook endpoints, revoke keys. |
| Webhooks | Register endpoint URLs, view their signing secrets, filter by event type, send test events, inspect recent deliveries. |
| Widget Settings | Enable or disable individual verification rails (SSA, IRS, payroll, bank). Customize widget branding (logo, display name, primary color). |
| Verifications | Browse 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.
| Environment | Dashboard | API base URL |
|---|---|---|
| Sandbox | sandbox.transcrypts.com | https://sandbox.transcrypts.com |
| Production | app.transcrypts.com | https://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 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.
| SSN | Terminal webhook event | Notes |
|---|---|---|
000-00-0001 | verification_session.verified | result_data has 2 employers spanning 2020–present with year-by-year earnings. |
000-00-0002 | verification_session.failed | failure_reason.code = "no_match". |
Any other SSN | verification_session.failed | failure_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.
| OTP | Terminal webhook event | Notes |
|---|---|---|
123456 | verification_session.verified | Happy path. Full SSE stage sequence; result_data has 2 employers + 3 years of W-2 data. |
999999 | verification_session.verified | Same as 123456 with 10× delays. Use to test the long-running user-waits UX. |
000000 | verification_session.failed | OTP 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. |
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.
# 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 transcrypts2. Create a session on your server
Use your secret key (sk_live_*) to create a session. Never expose this key in the browser.
// 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_*).
// 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.
// 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.
| Prefix | Type | Used where |
|---|---|---|
pk_live_* | Publishable | Frontend 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_* | Secret | Server-side API calls (create / retrieve verification sessions). Never expose in browser. |
cs_* | Client secret | Short-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 secret | One 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).
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
// 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, ... }| Field | Type | Required | Description |
|---|---|---|---|
applicant_hint | object | 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. |
metadata | object | Optional | Arbitrary JSON, echoed back unchanged in every webhook event and in GET /verification_sessions/:id. See the round-trip example below. |
return_url | string | Optional | URL the user is sent to after closing the widget. Used by redirect-based rails. |
{
"id": "vs_01H8X7B3K5Z6Y2W9V4Q1N8M7L0",
"client_secret": "cs_live_zR3vP9...xK1qLp",
"status": "requires_input",
"expires_at": "2026-05-20T19:14:00Z"
}| Field | Type | Description |
|---|---|---|
id | string | Unique session identifier, prefixed vs_. Echoed on every webhook event and retrievable via GET /verification_sessions/:id. |
client_secret | string | Single-use credential prefixed cs_. Pass this to your frontend; the widget authenticates with it. Do not log or persist. |
status | enum | Initial state, always "requires_input" at creation. See the Status enum below. |
expires_at | string | ISO 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.
// 1. When the user clicks "Verify employment" 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 },
});
}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
// 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.
{
"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"
}| Field | Type | Description |
|---|---|---|
id | string | Session identifier, prefixed vs_. |
status | enum | Current lifecycle state. One of requires_input, processing, verified, failed, canceled, expired. |
vendor | enum | null | Verification rail the user chose. One of transcrypts, irs. Null until the widget picks one. |
applicant_hint | object | null | Pre-fill hints echoed back from session creation. See the applicant_hint schema below. |
result_data | object | null | Normalized verification result. Populated only when status is verified. See the result_data schema below. |
failure_reason | object | null | Populated only when status is failed. See the failure_reason schema below. |
metadata | object | Arbitrary JSON you supplied at creation, echoed back unchanged. |
return_url | string | null | URL the user is sent to after closing the widget, if you supplied one. |
created_at | string | ISO 8601 timestamp when the session was created. |
updated_at | string | ISO 8601 timestamp of the last status transition. |
expires_at | string | ISO 8601 timestamp after which the client_secret stops authenticating. |
| Field | Type | Description |
|---|---|---|
requires_input | string | Session created. Awaiting the user to pick a rail and complete it in the widget. |
processing | string | User has started a rail. Async backend work is in progress. Some rails go straight from requires_input to verified and skip this state. |
verified | string | Verification succeeded. result_data is populated. Terminal state. |
failed | string | Verification could not be completed. failure_reason is populated. Terminal state. |
canceled | string | User closed the widget without finishing. Only reached from requires_input or processing. Terminal state. |
expired | string | client_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.
| Field | Type | Description |
|---|---|---|
firstName | string | Optional. Pre-fills the First Name field in the Transcrypts rail form. |
lastName | string | Optional. Pre-fills the Last Name field in the Transcrypts rail form. |
email | string | Optional. 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.
| Field | Type | Description |
|---|---|---|
subject | object | null | Personal 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. |
employers | array<Employer> | Employment records returned by the rail. May be empty if the rail returned no matches. |
earnings | array<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.
| Field | Type | Description |
|---|---|---|
name | string | null | Full legal name as recorded by the data source. IRS returns it as "LAST FIRST MIDDLE" (uppercased). |
address | string | null | Mailing address as recorded by the data source. May contain newline characters between street and city/state/ZIP. |
ssn | string | null | Always 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
| Field | Type | Description |
|---|---|---|
name | string | Employer name. |
jobTitle | string | null | Job title at this employer, when the rail provides it. Null on the IRS rail (W-2 transcripts do not carry job title). |
startDate | string | null | Real 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. |
endDate | string | null | Real ISO end date. On date-precise rails, null means current employment. Always null on IRS, which has no end date — see endYear. |
startYear | number | null | Earliest year of employment. On IRS, the earliest tax year with reported income. |
endYear | number | null | Latest year of employment. On IRS, the latest tax year with reported income — not a termination; check employmentStatus. |
employmentStatus | enum | null | One of active, terminated, unknown. |
source | enum | Which rail produced this record. One of transcrypts, irs. |
confidence | enum | Optional. One of high, medium, low. Populated by the Transcrypts rail; omitted on IRS. |
Earning
| Field | Type | Description |
|---|---|---|
year | number | Tax year. |
employerName | string | null | Employer name from the W-2. |
employerEin | string | null | Employer EIN from the W-2, formatted XX-XXXXXXX. |
totalWages | number | Wages, tips, and other compensation in dollars (W-2 box 1). |
| Field | Type | Description |
|---|---|---|
code | string | Machine-readable code. Examples: no_match, invalid_otp, auth_failed, post_otp_failure. |
message | string | Human-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
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)',
},
});| Option | Type | Required | Description |
|---|---|---|---|
publishableKey | string | 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. |
apiBaseUrl | string | Optional | Explicit base URL. Overrides "mode" when set. Use for local development ("http://localhost:3000") or custom domains. Most integrators do not need this. |
containerStyle | CSSStyleDeclaration | 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.
// 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);{ 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.
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');
});| Event | Payload | Description |
|---|---|---|
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)→() => voidSubscribe 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
| Event | Fired when |
|---|---|
verification_session.created | A session was created via POST /v1/verification_sessions. |
verification_session.processing | The user picked a rail and submitted their input. Verification work has started; final outcome will arrive as verified or failed. |
verification_session.verified | Verification succeeded. data.result_data contains the normalized employers + earnings. |
verification_session.failed | Verification could not be completed. data.failure_reason contains code + message. |
verification_session.canceled | User 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.expired | client_secret aged out (30 min by default) before the user opened the widget. |
webhook.test | Sent 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.
// 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" }
}
}| Field | Type | Description |
|---|---|---|
id | string | Unique event identifier, prefixed evt_. Use this for idempotency · safe to re-process the same id without side effects. |
type | enum | Event type. See the Event Types table above for the full list. |
created_at | string | ISO 8601 timestamp when the event was generated server-side. |
environment | enum | One of sandbox, production. Lets a shared handler distinguish test traffic from real. |
data | object | The canonical verification session object. See the response schema for GET /verification_sessions/:id for every field. |
| Field | Type | Description |
|---|---|---|
2xx status | response | Marks 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 timeout | response | Treated 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.
// 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)
);
}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.
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.
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.
| Setting | Description |
|---|---|
| Logo | Replaces the Transcrypts logo in the widget header. PNG or SVG, max 500KB. |
| Display name | Replaces "Transcrypts" in body copy. Used in headers and CTAs. |
| Primary color | Hex color used for buttons, links, and accents. |
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.