Skip to content

API Reference

Complete method reference for the TypeScript SDK. Endpoints, requests, emails, forwarding, streaming, matchers, and error handling with code examples.

Updated Nov 2026

Constructor

import { WebhooksCC } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({
  apiKey: string,        // Your API key. Required unless getAccessToken is given.
  getAccessToken?: (o?: { forceRefresh?: boolean }) => Promise<string>,
                         // Optional. Supplies an expiring token instead (agents).
  baseUrl?: string,      // Optional. Override API base URL.
  webhookUrl?: string,   // Optional. Override webhook receiver URL.
  timeout?: number,      // Optional. Request timeout in ms (default: 30000).
  retry?: RetryOptions,  // Optional. Automatic retry configuration.
  hooks?: ClientHooks,   // Optional. Lifecycle hooks for observability.
  emailDomain?: string,  // Optional. Domain for emails.address() (default: mailhooks.cc).
  fetch?: typeof fetch,  // Optional. The fetch every call goes through (proxies, tests).
  userAgent?: string,    // Optional. Your product token, like "my-tool/1.2", sent before
                         // the SDK's (webhooks-cc-sdk/<version>) in the User-Agent.
});

Retries are off by default (maxAttempts: 1). With retry, GET, PUT and DELETE calls are repeated after a network error, a timeout or a status in retryOn (429, 500, 502, 503 and 504 by default), and any call after a 429, which the API sends before doing anything. A POST or PATCH that failed any other way is not repeated: it may have gone through, and a second endpoints.create() would leave an extra endpoint. Nor is requests.clear() without before, whose repeat would also delete what arrived in between.

Endpoints

POST/endpoints

client.endpoints.create

Create a new webhook endpoint. The slug is auto-generated.

create(options?: CreateEndpointOptions): Promise<Endpoint>
ParamTypeRequiredDescription
namestringnoDisplay name
ephemeralbooleannoAuto-delete after TTL
expiresInstring | numbernoCustom expiry duration, like "12h" or "7d"; implies ephemeral
mockResponseMockResponsenoMock response config
responseRulesResponseRule[]noResponse rules, first match wins
notificationUrlstringnoAdds a Notifications destination: a chat message, at most one a second
siteEndpointSitenoWhich site's dashboard lists it: "webhooks", "mailhooks" or "both" (the default). Listing only; both addresses work either way

MockResponse fields:

FieldTypeRequiredDescription
statusnumberyesHTTP status code (100-599)
bodystringnoResponse body
headersRecord<string, string>noResponse headers
delaynumbernoResponse delay in ms (0-30000)
GET/endpoints

client.endpoints.list

List the endpoints you own (each with sharedWith) and those shared with you through a subscribed team (each with fromTeam, the oldest share, and fromTeams, every one of your teams it is shared with), as one array. Each carries favorite, whether you marked it as a favourite.

list(options?: ListEndpointsOptions): Promise<Endpoint[]>
ParamTypeRequiredDescription
teamstringnoKeep only endpoints tied to this team, matched via fromTeams, fromTeam, or sharedWith. A team id or a unique name (case-insensitive), resolved through teams.list(); an ambiguous name throws
GET/endpoints/:slug

client.endpoints.get

Get a single endpoint by slug.

get(slug: string): Promise<Endpoint>
PATCH/endpoints/:slug

client.endpoints.update

Update an endpoint's settings. Only the options you pass change.

update(slug: string, options: UpdateEndpointOptions): Promise<Endpoint>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
namestringnoNew display name
mockResponseMockResponse | nullnoDefault response (see fields above), or null to clear
responseRulesResponseRule[] | nullnoResponse rules, or null to clear
notificationUrlstring | nullnoSets the Notifications destination, or null removes it
showEmailExtractsbooleannoPick codes and links out of emails (owner only)
siteEndpointSitenoWhich site's dashboard lists it (owner only)
emailFieldsEmailField[] | nullnoNamed fields to pick out of every email, at most 20; replaces the list (owner only)
layoutsLayout[] | nullnoLayouts that read the endpoint's emails before delivery, at most 50; replaces the list (owner only)
retentionDaysnumber | nullnoKeep requests this many days (1 up to the plan's), or null for the plan's (owner only)
deleteAfterDeliverybooleannoDelete each request once every delivery is settled (owner only)
signingProviderstring | nullnoProvider for server-side signature verification, or null to turn it off
signingSecretstring | nullnoThe provider's signing secret (stored encrypted, never returned)
signingHeaderstring | nullnoThe header name for the generic-hmac provider

Mock response bodies and header values can hold placeholders such as {{ json.id }}, filled in from each request.

POST/endpoints/:slug/responses/preview

client.endpoints.previewResponse

A dry run: which response rule answers a captured request, and the response with its placeholders filled in, exactly as the receiver would send it. Nothing is sent.

previewResponse(slug: string, options?: PreviewResponseOptions): Promise<ResponsePreview>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
requestIdstringnoA captured request on the endpoint (default: the newest HTTP request)
mockResponseMockResponse | nullnoTry an unsaved default response; left out, the saved one applies
responseRulesResponseRule[] | nullnoTry unsaved rules; left out, the saved ones apply

Returns rule ({ index, name } of the rule that answers, or null for the default response), response ({ status, headers, body, delay }, or null for the plain 200 OK), unknownPlaceholders (sent as written), email (true when the request is an email, which never gets a mock response) and request (the request it ran against; sample when none was captured yet).

const preview = await client.endpoints.previewResponse("payments", {
  responseRules: [
    {
      conditions: [{ field: "body_path", op: "gt", path: "data.amount", value: "10000" }],
      response: { status: 422, body: '{"amount": {{ json.data.amount }}}', headers: {} },
    },
  ],
});

The SDK also exports the rule language itself: evaluateConditions(group, subject) checks conditions against a request and renderTemplate(template, context) fills in placeholders, with the same results as the receiver.

POST/endpoints/:slug/layouts/preview

client.endpoints.previewLayouts

A dry run of the endpoint's layouts on a captured email: which layout matches and what it finds. Uses the endpoint's newest email unless requestId is given, and the saved layouts unless layouts are. Nothing is saved or sent and no parsed pages are used: attachments show an earlier parse, never a new one. Owner only.

previewLayouts(slug: string, options?: { layouts?: Layout[]; requestId?: string }): Promise<LayoutsPreview>
// Returns: { problems, layoutsCap, request, parsed }
POST/endpoints/:slug/replay

client.endpoints.redeliver

Send the endpoint's requests received in a time range through its destinations again, at most 500, oldest first: after a pattern was fixed or a destination came back. Owner only.

redeliver(
  slug: string,
  range?: { since?: number; until?: number; kind?: "http" | "email" }
): Promise<{ requests: number; queued: number }>
DELETE/endpoints/:slug

client.endpoints.delete

Delete an endpoint and all its captured requests.

delete(slug: string): Promise<void>
PUT/endpoints/:slug/favorite

client.endpoints.favorite

Mark an endpoint as one of your favourites, and unfavorite to unmark it. A favourite is your own choice, never the endpoint's, so you can mark endpoints shared with you through a team too. Both are idempotent and return the new state; list() and get() report it as favorite. They need an account: sandbox tokens are refused.

favorite(slug: string): Promise<{ favorite: boolean }>   // PUT /endpoints/:slug/favorite
unfavorite(slug: string): Promise<{ favorite: boolean }> // DELETE /endpoints/:slug/favorite
await client.endpoints.favorite("payments");
const favourites = (await client.endpoints.list()).filter((e) => e.favorite);
POST/w/:slug

client.endpoints.send

Send a test webhook to an endpoint. Sends directly to the receiver, does not go through the API.

send(slug: string, options?: SendOptions): Promise<Response>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
methodstringnoHTTP method (default: "POST")
headersRecord<string, string>noHTTP headers to include
bodyunknownnoRequest body (JSON-serialized)
POST/w/:slug

client.endpoints.sendTemplate

Send a provider template webhook with generated signature headers.

sendTemplate(slug: string, options: SendTemplateOptions): Promise<Response>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
providerTemplateProvideryesProvider template to generate
templatestringnoProvider-specific template preset
secretstringyesShared secret for signature generation; not needed for unsigned templates (secretRequired: false, such as sendgrid, discord, plaid)
eventstringnoProvider event/topic override
methodstringnoHTTP method override (defaults to POST)
timestampnumbernoUnix timestamp override for signature generation
messageIdstringnoMessage id for standard-webhooks, clerk and resend signatures (random by default); not in SDK 1.15.0 or earlier
headersRecord<string, string>noAdditional headers
bodyunknownnoTemplate body override

Direct Delivery

POST(any URL)

client.sendTo

Send a webhook directly to any URL with optional provider signing. Use it for local integration testing: send properly signed webhooks to localhost handlers without routing through webhooks.cc.

sendTo(url: string, options?: SendToOptions): Promise<Response>
ParamTypeRequiredDescription
urlstringyesTarget URL (http or https)
providerTemplateProvidernoProvider template for signing
templatestringnoProvider-specific template preset, like "checkout.session.completed"; its sample body is sent when body is left out
secretstringnoSecret for signature generation (required when provider is set)
bodyunknownnoRequest body (JSON-serialized if not a string)
headersRecord<string, string>noAdditional headers
methodstringnoHTTP method (default: "POST")
eventstringnoEvent name
timestampnumbernoUnix timestamp override for deterministic signatures in tests
messageIdstringnoMessage id for standard-webhooks, clerk and resend signatures (random by default); with timestamp, makes them deterministic. Not in SDK 1.15.0 or earlier
POST(build only)

client.buildRequest

Build a signed webhook request without sending it. Returns the computed URL, method, headers, and body.

buildRequest(url: string, options?: SendToOptions): Promise<{url, method, headers, body?}>

Requests

GET/endpoints/:slug/requests

client.requests.list

List captured requests for an endpoint.

list(endpointSlug: string, options?: ListRequestsOptions): Promise<Request[]>
ParamTypeRequiredDescription
endpointSlugstringyesEndpoint slug
limitnumbernoMax results (default: 50)
sincenumbernoOnly return requests after this timestamp (ms)
kind"http" | "email"noOnly HTTP requests, or only emails
GET/endpoints/:slug/requests

client.requests.listPaginated

List captured requests with cursor-based pagination.

listPaginated(slug: string, options?: ListPaginatedRequestsOptions): Promise<PaginatedResult<Request>>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
limitnumbernoMax results per page
cursorstringnoCursor from previous page
kind"http" | "email"noOnly HTTP requests, or only emails
GET/requests/:id

client.requests.get

Get a single captured request by ID. For a captured email, kind is "email" and email holds the parsed message; client.emails.get() returns the same request typed as an EmailRequest.

get(requestId: string): Promise<Request>

When the endpoint has layouts, parsed holds what they found once the parse is done.

GET/requests/:id/raw

client.requests.raw

A captured email's exact message (RFC 5322 bytes), and attachment() one of its attachments by its index in email.attachments, from 0 to 99. Both work for emails kept in storage (email.stored).

raw(requestId: string): Promise<Uint8Array>
attachment(requestId: string, index: number): Promise<AttachmentDownload> // { bytes, filename, contentType }
POST/requests/:id/replay

client.requests.redeliver

Send a captured request through its endpoint's destinations again, with their current settings. True when at least one delivery was queued. To send it to one destination only, use forwarding.redeliver().

redeliver(requestId: string): Promise<boolean>
GET/endpoints/:slug/requests

client.requests.waitFor

Poll for incoming requests until one matches or timeout expires. Accepts human-readable duration strings.

waitFor(endpointSlug: string, options?: WaitForOptions): Promise<Request>
ParamTypeRequiredDescription
endpointSlugstringyesEndpoint slug to monitor
timeoutnumber | stringnoMax wait time: ms or "30s", "5m", "1h" (default: 30000)
pollIntervalnumber | stringnoInterval between polls (default: 500)
match(request) => booleannoFilter function
sincenumbernoOnly consider requests after this timestamp in ms (default: 5 minutes ago)

The five-minute lookback means a request that arrived just before the call is still found. Pass since: Date.now() to wait only for new requests.

GET/endpoints/:slug/requests

client.requests.waitForAll

Wait for multiple matching requests to arrive.

waitForAll(slug: string, options: WaitForAllOptions): Promise<Request[]>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
countnumberyesNumber of requests to collect
timeoutnumber | stringnoMax wait time
match(request) => booleannoFilter function
sincenumbernoOnly consider requests after this timestamp (ms)
GET/api/stream/:slug

client.requests.subscribe

Stream incoming requests via SSE as an async iterator.

subscribe(slug: string, options?: SubscribeOptions): AsyncIterable<Request>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
signalAbortSignalnoSignal to cancel the subscription
timeoutnumber | stringnoMax stream duration
reconnectbooleannoAuto-reconnect on disconnect
maxReconnectAttemptsnumbernoMax reconnection attempts (default: 5)
reconnectBackoffMsnumber | stringnoBase backoff between reconnects (default: 1000)
idleTimeoutnumber | stringnoTreat the stream as dead after this long without data (default: "90s"; 0 turns it off)
onReconnect(attempt) => voidnoCalled before each reconnect attempt

Streamed requests carry kind, so you can tell emails apart, but not the parsed email. Read it with client.emails.get(request.id).

POST/requests/:id/replay

client.requests.replay

Replay a captured request to a target URL. Sends the original method, headers, and body. Hop-by-hop headers are stripped. Throws for a captured email, which is not an HTTP request; to post an email to a handler, send client.emails.toJson(email) with sendTo().

replay(requestId: string, targetUrl: string): Promise<Response>
ParamTypeRequiredDescription
requestIdstringyesID of the captured request
targetUrlstringyesURL to send the replayed request to
GET/requests/search

client.requests.search

Search captured webhook requests across endpoints with full-text search.

search(filters?: SearchFilters): Promise<SearchResult[]>
ParamTypeRequiredDescription
slugstringnoFilter by endpoint slug
methodstringnoFilter by HTTP method
kind"http" | "email"noOnly HTTP requests, or only emails
qstringnoFree-text search
fromstring | numbernoStart time (duration or timestamp)
tostring | numbernoEnd time (duration or timestamp)
limitnumbernoMax results (default: 50)
offsetnumbernoPagination offset
order"asc" | "desc"noSort order (default: "desc")
GET/requests/count

client.requests.count

Count captured webhook requests matching the given filters.

count(filters?: SearchFilters): Promise<number>
DELETE/endpoints/:slug/requests

client.requests.clear

Delete captured requests for an endpoint without deleting the endpoint itself.

clear(slug: string, options?: ClearRequestsOptions): Promise<void>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
beforestring | numbernoDelete requests before this duration/timestamp
GET/endpoints/:slug/requests/export

client.requests.export

Export captured requests as HAR 1.2 or cURL commands. Emails are skipped: they have no HTTP request to export.

export(slug: string, options: ExportRequestsOptions): Promise<RequestsExport>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
format"har" | "curl"yesExport format
limitnumbernoMax requests to export
sincenumbernoOnly export requests after this timestamp

Emails

Every endpoint on an account receives email at <slug>@mailhooks.cc, and at <slug>+<tag>@mailhooks.cc for any tag. Guest endpoints receive none. A captured email is a request with kind: "email", method: "EMAIL", the recipient address as path, the raw message as body, and the parsed message as email; the SDK types it as EmailRequest. Each email counts as one request against your quota. See Email capture for what is stored.

Use a fresh tag per test run, so each run finds its own email:

import { extractCode } from "@webhooks-cc/sdk";
 
const runId = `signup-${Date.now()}`;
await signUp(client.emails.address(slug, runId)); // your app sends the email
 
const email = await client.emails.waitFor(slug, { tag: runId });
const code = extractCode(email); // "482913"

list, latest, waitFor and waitForAll filter by the same criteria. Every criterion you pass must match:

CriterionTypeMatches
tagstring | nullThe address's +tag, exactly (tags are case-sensitive). null matches untagged mail only
subjectstring | RegExpA string matches when the subject contains it; a RegExp is tested against the subject
fromstring | RegExpAny From address. A string must equal the address (case-insensitive); a RegExp is tested against Name <address>
tostring | RegExpAny To or Cc address, or the address the email was delivered to, with the same rules as from
GET(local)

client.emails.address

The address an endpoint receives email at, with an optional tag. Pass the endpoint object (from endpoints.create() or endpoints.get()) to use the address the server reports; a slug builds the address with the client's emailDomain. Throws on an invalid slug or tag. A tag is printable ASCII without spaces, quotes, brackets, @, ,, ; or :, and the slug, the + and the tag together are at most 64 characters.

address(endpoint: string | Pick<Endpoint, "slug" | "emailAddress">, tag?: string): string
client.emails.address("my-app"); // "my-app@mailhooks.cc"
client.emails.address("my-app", "run-42"); // "my-app+run-42@mailhooks.cc"
GET/endpoints/:slug/requests?kind=email

client.emails.list

Emails an endpoint captured, newest first, filtered by the criteria above.

list(endpointSlug: string, options?: ListEmailsOptions): Promise<EmailRequest[]>
ParamTypeRequiredDescription
endpointSlugstringyesEndpoint slug
tag, subject, from, tocriterianoSee the criteria table
limitnumbernoHow many of the newest emails to fetch before filtering (default: 50)
sincenumbernoOnly emails received after this timestamp (ms)
GET/requests/:id

client.emails.get

One captured email by request ID, with the endpoint's named fields as emailFields and the reply text as replyText. Throws NotFoundError when the request is not an email.

get(requestId: string): Promise<EmailRequest>
GET/endpoints/:slug/requests?kind=email

client.emails.latest

The newest email matching the criteria, or null. Looks at the 100 newest emails.

latest(endpointSlug: string, criteria?: EmailCriteria): Promise<EmailRequest | null>
GET/endpoints/:slug/requests?kind=email

client.emails.waitFor

Poll until an email matching the criteria arrives. Throws TimeoutError when none does.

waitFor(endpointSlug: string, options?: WaitForEmailOptions): Promise<EmailRequest>
ParamTypeRequiredDescription
endpointSlugstringyesEndpoint slug
tag, subject, from, tocriterianoSee the criteria table
timeoutnumber | stringnoMax wait time, ms or a duration like "30s" (default: 60000)
pollIntervalnumber | stringnoInterval between polls (default: 1000)
sincenumbernoOnly emails received after this timestamp in ms (default: 5 minutes ago)
match(email: EmailRequest) => booleannoExtra filter on top of the criteria

The five-minute lookback finds an email that arrived just before the call. Use a fresh tag, or pass since: Date.now() before triggering the email, so an older email cannot match.

GET/endpoints/:slug/requests?kind=email

client.emails.waitForAll

Poll until count matching emails have arrived. Returns them oldest first. Takes the same options as waitFor, plus count.

waitForAll(endpointSlug: string, options: WaitForEmailsOptions): Promise<EmailRequest[]>
POST/send-test-email

client.emails.sendTest

Deliver a sample email with a six-digit code and a link to an endpoint, optionally to a tagged address. It is captured and counted like any other email, but it does not travel over SMTP, so no sender checks run on it and email.smtp.test is true. Rate limited to 10 per minute.

sendTest(endpointSlug: string, options?: { tag?: string }): Promise<SendTestEmailResult>
// Returns: { status: "captured", requestId: string | null }
GET(local)

client.emails.toJson

The email.received JSON that forwarding would post for an email: an EmailReceivedEvent. No API call is made. Pass endpoint to fill in data.endpoint.name; without it the slug is read from the address and the name is null. Set includeExtracts: false to leave out codes and links, as forwarding does when the endpoint turned them off.

toJson(
  email: EmailRequest,
  options?: {
    endpoint?: { slug: string; name?: string | null };
    includeExtracts?: boolean;
    fields?: Record<string, string | null> | null; // default: email.emailFields
  }
): EmailReceivedEvent
GET(local)

client.emails.toInbound

The email in the shape an inbound mail service posts it, to test the handler you wrote for that service: postmark, sendgrid (a multipart form), mailgun (a form, signed when you pass your Mailgun signing key) or cloudmailin. No API call is made. Post body with contentType to your handler. See In a mail provider's shape.

toInbound(
  email: EmailRequest,
  format: "postmark" | "sendgrid" | "mailgun" | "cloudmailin",
  options?: { mailgunSigningKey?: string }
): Promise<{ contentType: string; body: string }>
POST/requests/:id/render

client.emails.render

The same, rendered by webhooks.cc (what whk tunnel and the Python and Go SDKs use), or the email.received JSON with format: "json".

render(
  requestId: string,
  options: { format: "json" | "postmark" | "sendgrid" | "mailgun" | "cloudmailin"; mailgunSigningKey?: string }
): Promise<{ format: string; method: "POST"; headers: Record<string, string>; body: string }>

extractCode, extractLink and extractFromEmail run the same finder as the dashboard's "Found in this email" strip and the codes and links of forwarded JSON. extractCode and extractLink take an EmailRequest, any Request (they return null for HTTP requests), or the parsed email; extractFromEmail takes the parsed email. None of them makes an API call.

import { extractCode, extractLink, extractFromEmail } from "@webhooks-cc/sdk";
 
extractCode(email); // "482913", or null
extractLink(email); // the confirm, verify, reset or sign-in link, else the first link worth keeping
extractLink(email, { actionOnly: true }); // only an action link, or null
extractFromEmail(email.email); // { codes: string[], links: { url, label, action }[] }

A code is only taken when a word like "code", "verification" or "sign in" sits next to it, so order numbers, prices and years stay out. See Matchers & Helpers for the rules.

The email entry point

@webhooks-cc/sdk/email exports the email types, the code and link finder (extractCode, extractLink, extractFromEmail, htmlToText), buildEmailJson, emailAddress and isValidEmailTag. It has no network code and imports no other SDK module, so it is small enough for a browser bundle.

import { emailAddress, extractCode, type EmailReceivedEvent } from "@webhooks-cc/sdk/email";
 
emailAddress("my-app", "run-42"); // "my-app+run-42@mailhooks.cc"

Forwarding

Forwarding sends what an endpoint captures to up to 10 destinations, each with its own kinds, conditions, format, timing and signing secret, queued, retried and logged. client.forwarding manages the endpoint's primary destination (the one its forwardUrl stands for) and reads the delivery log of all of them; client.destinations manages each one. Only the endpoint's owner can manage forwarding: for anyone else these methods throw NotFoundError, and configure() fails with a 403 for team members. In your handler, verify relayed requests with verifyRelayedRequest(), signed JSON and your own JSON with verifyForwardedRequest() or verifyForwardedEmail().

await client.forwarding.configure(slug, {
  url: "https://api.example.com/hooks",
  http: true,
  headers: [{ name: "Authorization", value: `Bearer ${process.env.HOOK_TOKEN}` }],
  sentField: "publishedAt",
});
const secret = await client.forwarding.secret(slug); // put it in your handler
 
const result = await client.forwarding.test(slug, { kind: "http" });
if (result.delivered) {
  await client.forwarding.configure(slug, { enabled: true });
}
PATCH/endpoints/:slug

client.forwarding.configure

Change the primary destination's settings and return the updated endpoint. Only the options you pass change. Saving the first URL creates the endpoint's signing secret, so you can send a test delivery before turning forwarding on. Turning forwarding, or one kind of it, off marks the deliveries still waiting as failed. Throws when no option is given.

configure(endpointSlug: string, options: ConfigureForwardingOptions): Promise<Endpoint>
ParamTypeRequiredDescription
endpointSlugstringyesEndpoint slug
urlstring | nullnoAn https URL on a public host name, or null to remove it (forwarding must be off)
enabledbooleannoTurn forwarding on or off. Turning it on needs a URL, sent now or saved before
httpbooleannoForward HTTP requests
emailbooleannoForward emails
format"auto" | "as_received" | "json" | "chat"noHow deliveries go out; "auto" picks chat for Slack and Discord URLs and as received otherwise. See Formats
headers{ name: string; value: string | null }[]noYour own headers, at most 10, for relayed and signed JSON deliveries. Replaces the whole list; null keeps the value stored under that name
appendPathbooleannoAdd the path after the slug to the URL for relayed requests (default true)
retrySeconds0 | 3600 | 86400noRetry failed deliveries for a day (default), an hour, or not at all
keepOrderbooleannoOne delivery at a time, in capture order (default false)
sentFieldstring | nullnoA JSON body field with the sender's own timestamp, such as "publishedAt" or "data.publishedAt". See Timing
GET/endpoints/:slug/forwarding

client.forwarding.secret

The endpoint's signing secret (whsec_...). Throws NotFoundError until a URL has been saved.

secret(endpointSlug: string): Promise<string>
POST/endpoints/:slug/forwarding

client.forwarding.rotateSecret

Replace the signing secret and return the new one. Every delivery from then on, retries included, is signed with it, so update your handler right away.

rotateSecret(endpointSlug: string): Promise<string>
POST/endpoints/:slug/forwarding/test

client.forwarding.test

Send the newest captured request of a kind (or a sample, when none has arrived) to the saved URL once, exactly as a delivery would go out, and report the answer. kind defaults to "http" when HTTP requests are forwarded, otherwise "email". Works with forwarding on or off; nothing is retried or logged. Fails with a 400 when no URL is saved. Rate limited to 10 per minute.

test(endpointSlug: string, options?: { kind?: "http" | "email" }): Promise<ForwardingTestResult>
// Returns: { status, durationMs, excerpt, error, delivered, sample, kind, format, url, target, request, sender }
GET/endpoints/:slug/deliveries

client.forwarding.deliveries

The endpoint's delivery log, newest first. Each row has its destination ({ id, name, url }), the status, your server's last answer and time, the format, the request's method and path (or an email's subject), when it was received, and senderAt when the request carried the sender's own timestamp.

deliveries(endpointSlug: string, options?: ListDeliveriesOptions): Promise<DeliveryLogEntry[]>
ParamTypeRequiredDescription
endpointSlugstringyesEndpoint slug
limitnumberno1 to 100 (default: 5)
status"all" | "pending" | "failed" | "skipped"no"pending" is queued or retrying, "skipped" filtered out (default: all)
destinationIdstringnoOnly this destination's deliveries
beforestringnoA row's cursor, to list the rows after it
GET/endpoints/:slug/deliveries/summary

client.forwarding.summary

Delivered and failed among the last 24 hours, waiting now, and the failed and total counts the log's filters show. Each count stops at 100,000.

summary(endpointSlug: string): Promise<DeliverySummary>
// Returns: { last24h: { delivered, failed }, pending, failed, total }
GET/requests/:id/deliveries

client.forwarding.requestDeliveries

Every delivery of one captured request or email, with each try in attemptLog. Anyone who can read the request can call this; team members get the destination's host without its path. emailDeliveries() is the older name for the same call.

requestDeliveries(requestId: string): Promise<ForwardingDelivery[]>
POST/requests/:id/deliveries

client.forwarding.redeliver

Queue a request or email for delivery again with the current settings, to every destination that takes it, and return the new delivery's id. It also sends one that was never forwarded. With destinationId, it goes to that destination only, whatever its filter says (the dashboard's Send anyway). Fails with a 409 when no destination takes it. Rate limited to 30 per minute.

redeliver(requestId: string, options?: { destinationId?: string }): Promise<{ id: string }>
POST/endpoints/:slug/deliveries/redeliver-failed

client.forwarding.redeliverFailed

Queue every request whose latest delivery failed again, oldest first, with each destination's current settings; with destinationId, only that destination's. At most 1,000 deliveries wait for a destination at a time, so queued can be lower than the failed count; call it again once those are through. Fails with a 409 when no destination is on. Rate limited to 10 per minute.

redeliverFailed(endpointSlug: string, options?: { destinationId?: string }): Promise<{ queued: number }>

Destinations

Each destination of an endpoint decides what it gets (http, email, a filter), how it is sent (format: "as_received", "json", "template" with your bodyTemplate, or "chat" with an optional messageTemplate, "sheets" rows in a Google Sheet, or "file_drop" files on your own server) and when (throttleSeconds, delaySeconds). See Forwarding for what each does.

const zap = await client.destinations.create(slug, {
  url: "https://hooks.zapier.com/hooks/catch/123/abc",
  email: false,
  filter: { conditions: [{ field: "body_path", op: "eq", path: "type", value: "invoice.paid" }] },
  format: "template",
  bodyTemplate: { invoice: "{{ json.data.object.id }}", amount: "{{ json.data.object.amount_paid }}" },
});
const preview = await client.destinations.preview(slug, { destinationId: zap.id });
// preview.filterPasses, preview.delivery?.body
GET/endpoints/:slug/destinations

client.destinations.list

The endpoint's destinations in order, header values masked. With summary: true, each has summary: its last 24 hours (delivered, failed, skipped) and what is waiting.

list(endpointSlug: string, options?: { summary?: boolean }): Promise<Destination[]>
get(endpointSlug: string, destinationId: string): Promise<Destination>
POST/endpoints/:slug/destinations

client.destinations.create

Add a destination, up to 10 per endpoint. It is on unless enabled: false, and gets a signing secret of its own. Templates and filters are checked before the request is sent, so a mistake throws with the field it is in.

create(endpointSlug: string, input: DestinationCreateInput): Promise<Destination>
// DestinationCreateInput: DestinationInput with a url, or a file drop's format and config
ParamTypeRequiredDescription
urlstringyesAn https URL on a public host name, or a Slack or Discord webhook (not for a file drop)
namestring | nullnoFor the list and the log
enabledbooleannoSend to it (default true)
http, emailbooleannoWhich kinds it takes (default both)
filter{ logic?: "and" | "or"; conditions: Condition[] } | nullnoSend only matching captures. See Send only some captures
formatDestinationFormatnoDefault "auto": chat for Slack and Discord URLs, as received otherwise. Also "as_received", "json", "template", "chat", "sheets" (Google Sheets), "file_drop" (File drops, Business), and for emails a mail provider's shape: "postmark", "sendgrid", "mailgun", "cloudmailin"
mailgunSigningKeystring | nullnoWith "mailgun": your Mailgun signing key, so signature verifies (write-only; hasMailgunSigningKey says one is saved)
bodyTemplateJSONnoWith "template": the body; its strings hold {{ placeholders }} (Your own JSON)
messageTemplatestring | nullnoWith "chat": your own message text; null sends the standard message
headers{ name: string; value: string | null }[]noYour own headers, at most 10 (not for chat). Replaces the list; null keeps a stored value
appendPathbooleannoAdd the captured path to the URL for relayed requests (default true)
retrySeconds0 | 3600 | 86400noRetry failed deliveries for a day (default), an hour, or not at all
keepOrderbooleannoOne delivery at a time, in capture order
sentFieldstring | nullnoA JSON body field with the sender's own timestamp
throttleSecondsnumbernoAt most one delivery every so many seconds (0 to 3,600); the rest are not sent
delaySecondsnumbernoWait this long after the capture before sending (0 to 3,600)
attachments"links" | "inline" | "none"noHow a stored email's attachments travel: signed links (default), inline base64, or names only
configSheetsConfig | FileDropConfig | nullnoWith "sheets": the spreadsheet, tab and columns; with "file_drop": the server, folder and file layout
dropSecret{ password?, privateKey?, secretAccessKey? } | nullnoWith "file_drop": the login secret, write-only, needed again when the server or login changes
positionnumbernoIts place in the list
PATCH/endpoints/:slug/destinations/:id

client.destinations.update

Change a destination; only the fields you pass change. Switching it off or taking a kind away marks what is waiting for it as failed.

update(endpointSlug: string, destinationId: string, input: DestinationInput): Promise<Destination>
DELETE/endpoints/:slug/destinations/:id

client.destinations.delete

Remove a destination. Deliveries still waiting for it are marked failed; its log stays.

delete(endpointSlug: string, destinationId: string): Promise<void>
PUT/endpoints/:slug/destinations/order

client.destinations.reorder

Put the destinations in this order (every id, once) and return them. Captures are queued for destinations in this order.

reorder(endpointSlug: string, destinationIds: string[]): Promise<Destination[]>
GET/endpoints/:slug/destinations/:id/secret

client.destinations.secret

The destination's signing secret (whsec_...); rotateSecret() replaces it and returns the new one, used by every delivery from then on.

secret(endpointSlug: string, destinationId: string): Promise<string>
rotateSecret(endpointSlug: string, destinationId: string): Promise<string>
POST/endpoints/:slug/destinations/:id/test

client.destinations.test

Send one delivery now, exactly as a real one would go out: requestId, or the newest request of kind, or a sample. Its filter is reported in filterPasses, not applied; nothing is retried or logged. Rate limited to 10 per minute.

test(
  endpointSlug: string,
  destinationId: string,
  options?: { kind?: "http" | "email"; requestId?: string }
): Promise<DestinationTestResult>
// Returns what forwarding.test() does, plus filterPasses
POST/endpoints/:slug/destinations/preview

client.destinations.preview

A dry run against a captured request: whether the destination takes its kind (takesKind), whether its filter lets it through (filterPasses), and the exact request it would send (delivery: method, URL, headers with stored values masked, body), or an error such as a template that cannot be filled in. Pass a saved destinationId, unsaved fields in destination (on top of the saved ones, or with a url on their own), or both. Nothing is sent.

preview(endpointSlug: string, options: PreviewDestinationOptions): Promise<DestinationPreview>

Alerts

GET/endpoints/:slug/alerts

client.alerts

An endpoint's alerts: when its email fields stop finding values, mail stops arriving, or a delivery fails for good. get() returns the settings and the recent alerts, open (resolvedAt: null) or resolved; update() changes only the settings you pass and returns them.

get(slug: string): Promise<{ settings: AlertSettings; events: AlertEvent[] }>
update(slug: string, settings: Partial<AlertSettings>): Promise<AlertSettings>
// AlertSettings: { enabled, fieldFailures, fieldFailureShare, requiredFields, silenceHours,
//                  destinationFailures, notifyEmail, notifyDestinationIds }
await client.alerts.update(slug, { enabled: true, silenceHours: 24, notifyEmail: true });

Templates

GET(local)

client.templates.listProviders

List all supported webhook provider templates. No API call made.

listProviders(): TemplateProvider[]
// Returns: ["stripe", "github", "shopify", "twilio", "slack", "paddle", "linear", "sendgrid", "clerk", "discord", "vercel", "gitlab", "typeform", "standard-webhooks", "meta", "lemonsqueezy", "coinbase-commerce", "razorpay", "cal", "intercom", "telegram", "square", "hubspot", "mailgun", "calendly", "mux", "sentry", "bitbucket", "docusign", "adyen", "paypal", "plaid", "resend", "workos"]
GET(local)

client.templates.get

Get metadata for a specific provider template.

get(provider: TemplateProvider): TemplateProviderInfo

Usage

GET/usage

client.usage

Get current request quota and usage.

usage(): Promise<UsageInfo>
// Returns: { used, limit, remaining, plan, periodEnd }

Teams

A key has exactly the team access of the account it belongs to. Endpoints shared with you already show up in endpoints.list() and work with every request method by slug except requests.clear, which stays with the owner; this namespace manages the teams themselves.

GET/teams

client.teams.list

List the teams you own or belong to, with role, seats, pooled usage, and subscription state.

list(): Promise<Team[]>
// Team: { id, name, role, seats, memberCount, suspended, subscriptionStatus,
//         requestsUsed, requestLimit, periodEnd, cancelAtPeriodEnd, ... }
GET/teams/:teamId/members

client.teams.members

List a team's members and pending invites. Any member may call this.

members(teamId: string): Promise<TeamMembers>
// Returns: { members: TeamMember[], pendingInvites: TeamInvite[] }
POST/teams/:teamId/endpoints

client.teams.share

Share an endpoint you own with a team. Resolves the slug to an endpoint id for you. The team needs an active subscription; the endpoint's requests then draw from the team's pooled quota.

share(teamId: string, slug: string): Promise<void>
DELETE/teams/:teamId/endpoints/:endpointId

client.teams.unshare

Stop sharing an endpoint you own with a team. Works on suspended teams too.

unshare(teamId: string, slug: string): Promise<void>
POST/teams/:teamId/invite

client.teams.invite

Invite an email address to a team you own. Sends an invite email; accepting claims one of the team's paid seats.

invite(teamId: string, email: string): Promise<TeamInvite>
GET/invites

client.teams.invites

Invites waiting for you, and the calls to answer them.

invites.list(): Promise<TeamInvite[]>
invites.accept(inviteId: string): Promise<void>
invites.decline(inviteId: string): Promise<void>

Flow Builder

POST(chained)

client.flow

Create a fluent workflow builder for common webhook capture, verify, and replay workflows.

const result = await client
  .flow()
  .createEndpoint({ name: "test" })
  .send({ body: { event: "test" } })
  .waitForCapture({ timeout: "10s" })
  .verifySignature({ provider: "stripe", secret: "whsec_..." })
  .replayTo("http://localhost:3000/webhooks")
  .cleanup()
  .run();

Sandbox and Agent Registration

AI agents can use webhooks.cc without an API key. WebhooksCC.sandbox() registers through auth.md, solves the proof-of-work challenge (about 3 seconds), and returns a sandbox client: up to 3 endpoints, 25 captured requests each and 100 in all, deleted 24 hours after registration. See AI Agents for the limits and how a human connects an agent.

POST/api/agent/identity

WebhooksCC.sandbox

const sandbox = await WebhooksCC.sandbox({ clientName: "my-agent" });
 
const endpoint = await sandbox.endpoints.create(); // endpoint.url captures webhooks
const request = await sandbox.requests.waitFor(endpoint.slug, { timeout: "60s" });
 
await sandbox.endpoints.list();
await sandbox.requests.list(endpoint.slug, { limit: 10 });
await sandbox.endpoints.delete(endpoint.slug); // frees a slot; the budget is not refunded

Sandbox endpoints only capture: no mock responses, notifications, signature verification, email or forwarding. Refusals throw SandboxError with the server's code: sandbox_endpoint_limit at 3 endpoints, sandbox_full when the shared sandbox is full. The access token renews itself.

POST/api/agent/identity/claim

sandbox.claim / sandbox.waitForClaim

Connects the agent to a human's account. Show them the link and the code together; only someone signed in with that email can complete it.

const { verificationUri, userCode } = await sandbox.claim({ email: "dev@example.com" });
console.log(`Open ${verificationUri}, sign in, and enter ${userCode}`);
 
const client = await sandbox.waitForClaim(); // a WebhooksCC acting for that account
const endpoints = await client.endpoints.list(); // includes the sandbox endpoints

waitForClaim() rejects with AgentAuthError expired_token when the 15-minute code runs out (call claim() again for a new one), or access_denied when the human declines. After the claim the sandbox client stops working; the returned client renews its token from the claimed assertion for 90 days.

GET(credential file)

WebhooksCC.connected / WebhooksCC.connect

The connection is saved in the agent credential file that the MCP server, the CLI and the Python and Go SDKs share (~/.config/whk/agent.json, or WHK_AGENT_FILE), so a later process picks it up. connected() returns a client for the connected account, or null when there is none or the human has disconnected the agent; fromSavedConnection() is the synchronous form that does not check first. connect() connects an agent to a human's account without a sandbox.

const client = (await WebhooksCC.connected()) ?? (await WebhooksCC.sandbox());
 
const pending = await WebhooksCC.connect({ email: "dev@example.com" });
console.log(`Open ${pending.verificationUri}, sign in, and enter ${pending.userCode}`);
const account = await pending.wait();
POST(protocol)

WebhooksCC.agent

The protocol steps one by one, for callers who drive auth.md themselves:

WebhooksCC.agent.discover({ baseUrl? })
WebhooksCC.agent.challenge()                      // a proof-of-work challenge
WebhooksCC.agent.solveChallenge(challenge, { signal? })
WebhooksCC.agent.registerAnonymous({ clientName? })   // challenge + solve + register
WebhooksCC.agent.registerServiceAuth({ email, clientName? }) // no sandbox; a human must connect it
WebhooksCC.agent.exchange({ assertion })          // one-hour access token
WebhooksCC.agent.startClaim({ claimToken, email? })
WebhooksCC.agent.pollClaim({ claimToken })
WebhooksCC.agent.waitForClaim({ claimToken, timeout? })
WebhooksCC.agent.revoke({ token })

The solver refuses challenges above 2^26 hashes, yields to the event loop about every 50 ms, and works in Node and browsers.

WebhooksCC.register.* (auth.md 0.1) is deprecated: anonymous and ID-JAG registration throw AgentRegisterError with code endpoint_moved, and the email flow stops working on November 30, 2026.

Introspection

GET(local)

client.describe

Returns a static description of all SDK operations and their parameters. No API call is made. Useful for AI agents and tool discovery.

describe(): SDKDescription

Matchers

Composable functions that return (request) => boolean. Use with waitFor or filter logic.

import {
  matchMethod,
  matchHeader,
  matchPath,
  matchQueryParam,
  matchBodyPath,
  matchBodySubset,
  matchContentType,
  matchJsonField,
  matchAll,
  matchAny,
  matchEmail,
  isEmailRequest,
} from "@webhooks-cc/sdk";
 
matchMethod("POST"); // match by HTTP method
matchHeader("x-github-event"); // match header presence
matchHeader("x-github-event", "push"); // match header value
matchPath("/webhooks/**"); // match path with glob wildcards
matchQueryParam("token"); // match query param presence
matchQueryParam("token", "abc"); // match query param value
matchBodyPath("data.object.id", "obj_123"); // match nested JSON path
matchBodySubset({ type: "checkout" }); // match deep partial subset
matchContentType("application/json"); // match content-type ignoring charset
matchJsonField("type", "checkout"); // match top-level JSON field
matchAll(matchMethod("POST"), matchHeader("stripe-signature"));
matchAny(matchMethod("GET"), matchMethod("POST"));
matchEmail({ tag: "run-42", subject: "Confirm" }); // match captured emails (see Emails)
isEmailRequest(request); // true for a captured email with its parsed message

matchEmail takes the email criteria and works as the match of requests.waitFor() and captureDuring(). It needs the parsed email, so it never matches requests from subscribe(); check request.kind === "email" there.

Provider Detection

Use detectWebhookInfo when you want the provider plus an event/topic label, or the is*Webhook helpers when you only need a boolean branch.

import { detectWebhookInfo, detectWebhookProvider, isTypeformWebhook } from "@webhooks-cc/sdk";
 
const info = detectWebhookInfo(request);
console.log(info?.provider, info?.event);
// "typeform", "form_response"
 
console.log(detectWebhookProvider(request));
// "typeform"
 
if (isTypeformWebhook(request)) {
  // Handle Typeform-specific fields
}

detectWebhookInfo returns:

type DetectedWebhookInfo = {
  provider:
    | "stripe"
    | "github"
    | "shopify"
    | "twilio"
    | "slack"
    | "paddle"
    | "linear"
    | "sendgrid"
    | "clerk"
    | "discord"
    | "vercel"
    | "gitlab"
    | "typeform"
    | "standard-webhooks"
    | "meta"
    | "lemonsqueezy"
    | "coinbase-commerce"
    | "razorpay"
    | "cal"
    | "intercom"
    | "telegram"
    | "square"
    | "hubspot"
    | "mailgun"
    | "calendly"
    | "mux"
    | "sentry"
    | "bitbucket"
    | "docusign"
    | "adyen"
    | "paypal"
    | "plaid"
    | "resend"
    | "workos";
  event: string | null;
  via: "header" | "body";
  matchedOn?: string;
};

Body Parsing

import { parseJsonBody, parseFormBody, parseBody, extractJsonField } from "@webhooks-cc/sdk";
 
// Parse JSON body safely (returns undefined on failure)
const json = parseJsonBody(request);
 
// Parse form-encoded body
const form = parseFormBody(request);
 
// Auto-detect and parse by content-type
const parsed = parseBody(request);
 
// Extract nested JSON field by dot notation
const email = extractJsonField<string>(request, "data.customer.email");

Payload Schemas

Generate types from captured requests: TypeScript interfaces, Zod schemas, Pydantic models, or JSON Schema. One schema is learned from every sample, so pass several requests of the same event to find optional fields (missing from some samples) and nullable ones (sometimes null).

import { generateRequestSchema, generatePayloadSchema } from "@webhooks-cc/sdk";
 
const requests = await client.requests.list("acme-stripe", { limit: 20 });
const { code, samples, skipped } = generateRequestSchema(requests, {
  format: "zod", // "typescript" (default), "zod", "pydantic", or "json-schema"
  name: "InvoicePaidEvent",
});
 
// From parsed values instead of requests
const ts = generatePayloadSchema([{ id: "evt_1", amount: 100 }], { name: "Payment" });

generateRequestSchema() reads JSON bodies, and form bodies as string fields; it skips emails and other bodies and counts them in skipped. payloadSample(request) returns the value it would use. Both run without network access, and @webhooks-cc/sdk/schema exports them without the client, for browser bundles.

Request Diffing

import { diffRequests } from "@webhooks-cc/sdk";
 
const diff = diffRequests(requestA, requestB);
console.log(diff.matches); // boolean
console.log(diff.differences); // { method?, path?, headers?, body? }

Signature Verification

import {
  verifySignature,
  verifyStripeSignature,
  verifyGitHubSignature,
  verifyShopifySignature,
  verifyTwilioSignature,
  verifySlackSignature,
  verifyPaddleSignature,
  verifyLinearSignature,
  verifyDiscordSignature,
  verifyClerkSignature,
  verifyVercelSignature,
  verifyGitLabSignature,
  verifyTypeformSignature,
  verifyStandardWebhookSignature,
  verifyMetaSignature,
  verifyLemonSqueezySignature,
  verifyCoinbaseCommerceSignature,
  verifyRazorpaySignature,
  verifyCalSignature,
  verifyIntercomSignature,
  verifyTelegramSignature,
  verifySquareSignature,
  verifyHubSpotSignature,
  verifyMailgunSignature,
  verifyCalendlySignature,
  verifyMuxSignature,
  verifySentrySignature,
  verifyBitbucketSignature,
  verifyDocuSignSignature,
  verifyAdyenSignature,
  verifyPayPalSignature,
  verifyResendSignature,
  verifyWorkOSSignature,
  verifyForwardedEmail,
  verifyForwardedRequest,
  verifyForwardedJson,
  verifyRelayedRequest,
} from "@webhooks-cc/sdk";
 
// Universal verifier (dispatches by provider)
const result = await verifySignature(request, {
  provider: "stripe",
  secret: "whsec_...",
});
console.log(result.valid); // boolean
 
// Provider-specific verifiers
const valid = await verifyStripeSignature(body, signatureHeader, secret);
const valid = await verifyGitHubSignature(body, signatureHeader, secret);

verifyForwardedEmail checks an email forwarded to your server: the Standard Webhooks signature against the endpoint's whsec_ secret and a timestamp within five minutes. It returns the typed email.received event, or throws WebhookVerificationError with a code. Pass the raw body (a string, Uint8Array or ArrayBuffer) before any JSON parsing.

verifyForwardedEmail(
  body: string | Uint8Array | ArrayBuffer,
  headers: HeadersInput,
  secret: string,
  options?: { toleranceSeconds?: number; now?: number } // defaults: 300, Date.now()
): Promise<EmailReceivedEvent>
 
// HeadersInput: a Fetch Headers, a plain object (Node and Express req.headers,
// array values included) or name and value pairs
type HeadersInput =
  | Headers
  | Record<string, string | string[] | undefined>
  | Iterable<readonly [string, string]>;

verifyStandardWebhookSignature(body, headers, secret) accepts the same HeadersInput, including a Fetch Headers. It checks the signature only, not the timestamp. See Signature Verification for a full handler.

Duration Strings

timeout and pollInterval accept human-readable strings alongside milliseconds.

"500ms"  →    500
"30s"    →  30000
"5m"     → 300000
"1h"     → 3600000
500      →    500    // numbers passed through

Types

interface Endpoint {
  id: string;
  slug: string;
  name?: string;
  url?: string;
  isEphemeral?: boolean;
  expiresAt?: number;
  createdAt: number;
  favorite?: boolean; // your own favourite; present on list() and get()
  emailAddress?: string | null; // null for guest endpoints, which receive no email
  showEmailExtracts?: boolean;
  site?: "webhooks" | "mailhooks" | "both";
  emailFields?: EmailField[]; // { name, from, header?, pattern }
  layouts?: Layout[]; // owner only
  retentionDays?: number | null; // owner only; null keeps the plan's
  deleteAfterDelivery?: boolean; // owner only
  sharedWith?: TeamShare[]; // { teamId, teamName }; when you own it
  fromTeams?: TeamShare[]; // when it is shared with you
  forwardEnabled?: boolean;
  forwardUrl?: string | null; // only returned to the owner
  hasForwardSecret?: boolean;
}
 
interface Request {
  id: string;
  endpointId: string;
  method: string;
  path: string;
  headers: Record<string, string>;
  body?: string;
  bodyRaw?: string; // base64 raw bytes, only for non-UTF-8 payloads
  queryParams: Record<string, string>;
  contentType?: string;
  ip: string;
  size: number;
  receivedAt: number;
  signatureVerified?: boolean | null;
  signatureError?: string | null;
  signingProvider?: string | null;
  kind?: "http" | "email";
  email?: EmailCapture | null; // the parsed message, for a captured email
  parsed?: ParsedEmail | null; // what the endpoint's layouts found, once parsed
}
 
// A captured email: method "EMAIL", path is the recipient address, body the raw message
interface EmailRequest extends Request {
  kind: "email";
  email: EmailCapture;
  emailFields?: Record<string, string | null> | null; // named fields, from emails.get()
  replyText?: string | null; // the new text of a reply, from emails.get()
}
 
interface EmailCapture {
  subject: string | null;
  from: EmailAddress[]; // EmailAddress: { name: string | null; address: string | null }
  to: EmailAddress[];
  cc: EmailAddress[];
  replyTo: EmailAddress[];
  sender: EmailAddress[];
  date: string | null;
  messageId: string | null;
  inReplyTo: string[];
  tag: string | null; // the +tag of the address it was sent to
  text: string | null; // up to 256 KB
  html: string | null; // up to 256 KB
  textFromHtml: boolean;
  attachments: {
    filename: string | null;
    contentType: string;
    size: number;
    contentId: string | null;
    inline: boolean;
  }[];
  auth: EmailAuth | null; // SPF, DKIM, DMARC and reverse DNS results
  spam?: EmailSpam | null; // the spam score
  smtp: EmailSmtp | null; // client IP, HELO, TLS, envelope; smtp.test marks sample emails
  parseError: boolean;
  stored?: boolean; // the raw message and attachments are kept: requests.raw(), requests.attachment()
  truncated: {
    raw: boolean;
    text: boolean;
    html: boolean;
    headers: boolean;
    addresses: boolean;
    attachments: boolean;
  };
}
 
// What extractFromEmail() returns
interface EmailExtracts {
  codes: string[]; // in order of appearance, subject first; spaces and dashes removed
  links: { url: string; label: string | null; action: boolean }[]; // action links first
}
 
interface EmailCriteria {
  tag?: string | null;
  subject?: string | RegExp;
  from?: string | RegExp;
  to?: string | RegExp;
}
 
interface ListEmailsOptions extends EmailCriteria {
  limit?: number; // emails fetched before filtering (default: 50)
  since?: number;
}
 
interface WaitForEmailOptions extends EmailCriteria {
  timeout?: number | string; // default: 60000
  pollInterval?: number | string; // default: 1000
  since?: number; // default: five minutes ago
  match?: (email: EmailRequest) => boolean;
}
 
interface WaitForEmailsOptions extends WaitForEmailOptions {
  count: number;
}
 
interface SendTestEmailResult {
  status: "captured";
  requestId: string | null;
}
 
interface ConfigureForwardingOptions {
  url?: string | null;
  enabled?: boolean;
}
 
interface ForwardingTestResult {
  status: number | null; // null when no answer arrived
  durationMs: number;
  excerpt: string | null; // the start of your server's response body
  error: string | null; // timeout, refused connection, TLS problem
  delivered: boolean; // your server answered 2xx
  sample: boolean; // no email had arrived, so a sample was sent
}
 
interface EmailDelivery {
  id: string;
  requestId: string;
  status: "pending" | "succeeded" | "failed";
  attempts: number;
  createdAt: number;
  finishedAt: number | null;
  nextAttemptAt: number | null;
  lastStatus: number | null;
  lastError: string | null;
  attemptLog: EmailDeliveryAttempt[];
}
 
interface EmailDeliveryAttempt {
  attemptedAt: number;
  status: number | null;
  durationMs: number;
  error: string | null;
  responseExcerpt: string | null;
}
 
interface RecentEmailDelivery {
  id: string;
  requestId: string;
  status: "pending" | "succeeded" | "failed";
  attempts: number;
  createdAt: number;
  lastStatus: number | null;
  lastError: string | null;
  subject: string | null;
}
 
// The body forwarding posts; field by field in /docs/forwarding#the-request
interface EmailReceivedEvent {
  type: "email.received";
  timestamp: string;
  data: EmailReceivedData;
}
 
interface MockResponse {
  status: number; // 100-599
  body: string;
  headers: Record<string, string>;
  delay?: number; // Response delay in ms (0-30000)
}
 
interface UsageInfo {
  used: number;
  limit: number;
  remaining: number;
  plan: "free" | "pro" | "business";
  periodEnd: number | null;
}
 
interface PaginatedResult<T> {
  items: T[];
  cursor?: string;
  hasMore: boolean;
}
 
type TemplateProvider =
  | "stripe"
  | "github"
  | "shopify"
  | "twilio"
  | "slack"
  | "paddle"
  | "linear"
  | "sendgrid"
  | "clerk"
  | "discord"
  | "vercel"
  | "gitlab"
  | "typeform"
  | "standard-webhooks"
  | "meta"
  | "lemonsqueezy"
  | "coinbase-commerce"
  | "razorpay"
  | "cal"
  | "intercom"
  | "telegram"
  | "square"
  | "hubspot"
  | "mailgun"
  | "calendly"
  | "mux"
  | "sentry"
  | "bitbucket"
  | "docusign"
  | "adyen"
  | "paypal"
  | "plaid"
  | "resend"
  | "workos";
 
type VerifyProvider = TemplateProvider;

Error Classes

Errors include actionable recovery hints when the server message is generic.

import {
  WebhooksCCError, // Base error (has statusCode)
  UnauthorizedError, // 401: includes link to get API key
  NotFoundError, // 404: suggests using endpoints.list()
  RateLimitError, // 429: includes retryAfter seconds
  TimeoutError, // Request timeout
  WebhookVerificationError, // Thrown by verifyForwardedEmail(); has a code
} from "@webhooks-cc/sdk";

WebhookVerificationError.code is "missing_headers", "timestamp_out_of_range", "invalid_signature" or "invalid_payload". Answer the first three with 401 and invalid_payload with 400.