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 Oct 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).
});

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
expiresInstringnoCustom expiry duration
mockResponseMockResponsenoMock response config
notificationUrlstringnoURL to POST a JSON summary on each request

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.

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 name or mock response configuration.

update(slug: string, options: UpdateEndpointOptions): Promise<Endpoint>
ParamTypeRequiredDescription
slugstringyesEndpoint slug
namestringnoNew display name
mockResponseMockResponse | nullnoMock response config (see fields above), or null to clear
notificationUrlstring | nullnoNotification webhook URL, or null to clear
DELETE/endpoints/:slug

client.endpoints.delete

Delete an endpoint and all its captured requests.

delete(slug: string): Promise<void>
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
eventstringnoProvider event/topic override
methodstringnoHTTP method override (defaults to POST)
timestampnumbernoUnix timestamp override for signature generation
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
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
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>
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

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
fromstringnoStart time (duration or timestamp)
tostringnoEnd 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
beforestringnoDelete 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"); // "[email protected]"
client.emails.address("my-app", "run-42"); // "[email protected]"
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. 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 }
): EmailReceivedEvent

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"); // "[email protected]"

Forwarding

Forwarding sends every HTTP request and email an endpoint captures to your server or a Slack or Discord channel, queued, retried and logged. HTTP requests go as received or as signed JSON, emails as signed JSON, and either as a chat message for Slack and Discord URLs. Only the endpoint's owner can manage it: 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 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 forwarding 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 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"no"pending" is queued or retrying (default: all)
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, and return the new delivery's id. It also sends one that was never forwarded. Fails with a 409 when forwarding of its kind is off. Rate limited to 30 per minute.

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

client.forwarding.redeliverFailed

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

redeliverFailed(endpointSlug: string): Promise<{ queued: number }>

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: "[email protected]" });
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.

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");

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,
  verifyResendSignature,
  verifyWorkOSSignature,
  verifyForwardedEmail,
} 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;
  emailAddress?: string | null; // null for guest endpoints, which receive no email
  showEmailExtracts?: boolean;
  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;
  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
}
 
// A captured email: method "EMAIL", path is the recipient address, body the raw message
interface EmailRequest extends Request {
  kind: "email";
  email: EmailCapture;
}
 
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
  smtp: EmailSmtp | null; // client IP, HELO, TLS, envelope; smtp.test marks sample emails
  parseError: boolean;
  truncated: {
    raw: boolean;
    text: boolean;
    html: boolean;
    headers: boolean;
    addresses: boolean;
    attachments: boolean;
  };
}
 
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";
  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.