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
/endpointsclient.endpoints.create
Create a new webhook endpoint. The slug is auto-generated.
create(options?: CreateEndpointOptions): Promise<Endpoint>| Param | Type | Required | Description |
|---|---|---|---|
name | string | no | Display name |
ephemeral | boolean | no | Auto-delete after TTL |
expiresIn | string | no | Custom expiry duration |
mockResponse | MockResponse | no | Mock response config |
notificationUrl | string | no | URL to POST a JSON summary on each request |
MockResponse fields:
| Field | Type | Required | Description |
|---|---|---|---|
status | number | yes | HTTP status code (100-599) |
body | string | no | Response body |
headers | Record<string, string> | no | Response headers |
delay | number | no | Response delay in ms (0-30000) |
/endpointsclient.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[]>| Param | Type | Required | Description |
|---|---|---|---|
team | string | no | Keep 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 |
/endpoints/:slugclient.endpoints.get
Get a single endpoint by slug.
get(slug: string): Promise<Endpoint>/endpoints/:slugclient.endpoints.update
Update an endpoint's name or mock response configuration.
update(slug: string, options: UpdateEndpointOptions): Promise<Endpoint>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | yes | Endpoint slug |
name | string | no | New display name |
mockResponse | MockResponse | null | no | Mock response config (see fields above), or null to clear |
notificationUrl | string | null | no | Notification webhook URL, or null to clear |
/endpoints/:slugclient.endpoints.delete
Delete an endpoint and all its captured requests.
delete(slug: string): Promise<void>/w/:slugclient.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>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | yes | Endpoint slug |
method | string | no | HTTP method (default: "POST") |
headers | Record<string, string> | no | HTTP headers to include |
body | unknown | no | Request body (JSON-serialized) |
/w/:slugclient.endpoints.sendTemplate
Send a provider template webhook with generated signature headers.
sendTemplate(slug: string, options: SendTemplateOptions): Promise<Response>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | yes | Endpoint slug |
provider | TemplateProvider | yes | Provider template to generate |
template | string | no | Provider-specific template preset |
secret | string | yes | Shared secret for signature generation |
event | string | no | Provider event/topic override |
method | string | no | HTTP method override (defaults to POST) |
timestamp | number | no | Unix timestamp override for signature generation |
headers | Record<string, string> | no | Additional headers |
body | unknown | no | Template body override |
Direct Delivery
(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>| Param | Type | Required | Description |
|---|---|---|---|
url | string | yes | Target URL (http or https) |
provider | TemplateProvider | no | Provider template for signing |
secret | string | no | Secret for signature generation (required when provider is set) |
body | unknown | no | Request body (JSON-serialized if not a string) |
headers | Record<string, string> | no | Additional headers |
method | string | no | HTTP method (default: "POST") |
event | string | no | Event name |
timestamp | number | no | Unix timestamp override for deterministic signatures in tests |
(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
/endpoints/:slug/requestsclient.requests.list
List captured requests for an endpoint.
list(endpointSlug: string, options?: ListRequestsOptions): Promise<Request[]>| Param | Type | Required | Description |
|---|---|---|---|
endpointSlug | string | yes | Endpoint slug |
limit | number | no | Max results (default: 50) |
since | number | no | Only return requests after this timestamp (ms) |
kind | "http" | "email" | no | Only HTTP requests, or only emails |
/endpoints/:slug/requestsclient.requests.listPaginated
List captured requests with cursor-based pagination.
listPaginated(slug: string, options?: ListPaginatedRequestsOptions): Promise<PaginatedResult<Request>>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | yes | Endpoint slug |
limit | number | no | Max results per page |
cursor | string | no | Cursor from previous page |
kind | "http" | "email" | no | Only HTTP requests, or only emails |
/requests/:idclient.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>/endpoints/:slug/requestsclient.requests.waitFor
Poll for incoming requests until one matches or timeout expires. Accepts human-readable duration strings.
waitFor(endpointSlug: string, options?: WaitForOptions): Promise<Request>| Param | Type | Required | Description |
|---|---|---|---|
endpointSlug | string | yes | Endpoint slug to monitor |
timeout | number | string | no | Max wait time: ms or "30s", "5m", "1h" (default: 30000) |
pollInterval | number | string | no | Interval between polls (default: 500) |
match | (request) => boolean | no | Filter function |
since | number | no | Only 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.
/endpoints/:slug/requestsclient.requests.waitForAll
Wait for multiple matching requests to arrive.
waitForAll(slug: string, options: WaitForAllOptions): Promise<Request[]>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | yes | Endpoint slug |
count | number | yes | Number of requests to collect |
timeout | number | string | no | Max wait time |
match | (request) => boolean | no | Filter function |
since | number | no | Only consider requests after this timestamp (ms) |
/api/stream/:slugclient.requests.subscribe
Stream incoming requests via SSE as an async iterator.
subscribe(slug: string, options?: SubscribeOptions): AsyncIterable<Request>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | yes | Endpoint slug |
signal | AbortSignal | no | Signal to cancel the subscription |
timeout | number | string | no | Max stream duration |
reconnect | boolean | no | Auto-reconnect on disconnect |
maxReconnectAttempts | number | no | Max 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).
/requests/:id/replayclient.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>| Param | Type | Required | Description |
|---|---|---|---|
requestId | string | yes | ID of the captured request |
targetUrl | string | yes | URL to send the replayed request to |
/requests/searchclient.requests.search
Search captured webhook requests across endpoints with full-text search.
search(filters?: SearchFilters): Promise<SearchResult[]>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | no | Filter by endpoint slug |
method | string | no | Filter by HTTP method |
kind | "http" | "email" | no | Only HTTP requests, or only emails |
q | string | no | Free-text search |
from | string | no | Start time (duration or timestamp) |
to | string | no | End time (duration or timestamp) |
limit | number | no | Max results (default: 50) |
offset | number | no | Pagination offset |
order | "asc" | "desc" | no | Sort order (default: "desc") |
/requests/countclient.requests.count
Count captured webhook requests matching the given filters.
count(filters?: SearchFilters): Promise<number>/endpoints/:slug/requestsclient.requests.clear
Delete captured requests for an endpoint without deleting the endpoint itself.
clear(slug: string, options?: ClearRequestsOptions): Promise<void>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | yes | Endpoint slug |
before | string | no | Delete requests before this duration/timestamp |
/endpoints/:slug/requests/exportclient.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>| Param | Type | Required | Description |
|---|---|---|---|
slug | string | yes | Endpoint slug |
format | "har" | "curl" | yes | Export format |
limit | number | no | Max requests to export |
since | number | no | Only 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:
| Criterion | Type | Matches |
|---|---|---|
tag | string | null | The address's +tag, exactly (tags are case-sensitive). null matches untagged mail only |
subject | string | RegExp | A string matches when the subject contains it; a RegExp is tested against the subject |
from | string | RegExp | Any From address. A string must equal the address (case-insensitive); a RegExp is tested against Name <address> |
to | string | RegExp | Any To or Cc address, or the address the email was delivered to, with the same rules as from |
(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): stringclient.emails.address("my-app"); // "[email protected]"
client.emails.address("my-app", "run-42"); // "[email protected]"/endpoints/:slug/requests?kind=emailclient.emails.list
Emails an endpoint captured, newest first, filtered by the criteria above.
list(endpointSlug: string, options?: ListEmailsOptions): Promise<EmailRequest[]>| Param | Type | Required | Description |
|---|---|---|---|
endpointSlug | string | yes | Endpoint slug |
tag, subject, from, to | criteria | no | See the criteria table |
limit | number | no | How many of the newest emails to fetch before filtering (default: 50) |
since | number | no | Only emails received after this timestamp (ms) |
/requests/:idclient.emails.get
One captured email by request ID. Throws NotFoundError when the request is not an email.
get(requestId: string): Promise<EmailRequest>/endpoints/:slug/requests?kind=emailclient.emails.latest
The newest email matching the criteria, or null. Looks at the 100 newest emails.
latest(endpointSlug: string, criteria?: EmailCriteria): Promise<EmailRequest | null>/endpoints/:slug/requests?kind=emailclient.emails.waitFor
Poll until an email matching the criteria arrives. Throws TimeoutError when none does.
waitFor(endpointSlug: string, options?: WaitForEmailOptions): Promise<EmailRequest>| Param | Type | Required | Description |
|---|---|---|---|
endpointSlug | string | yes | Endpoint slug |
tag, subject, from, to | criteria | no | See the criteria table |
timeout | number | string | no | Max wait time, ms or a duration like "30s" (default: 60000) |
pollInterval | number | string | no | Interval between polls (default: 1000) |
since | number | no | Only emails received after this timestamp in ms (default: 5 minutes ago) |
match | (email: EmailRequest) => boolean | no | Extra 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.
/endpoints/:slug/requests?kind=emailclient.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[]>/send-test-emailclient.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 }(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 }
): EmailReceivedEventCodes and links
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 });
}/endpoints/:slugclient.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>| Param | Type | Required | Description |
|---|---|---|---|
endpointSlug | string | yes | Endpoint slug |
url | string | null | no | An https URL on a public host name, or null to remove it (forwarding must be off) |
enabled | boolean | no | Turn forwarding on or off. Turning it on needs a URL, sent now or saved before |
http | boolean | no | Forward HTTP requests |
email | boolean | no | Forward emails |
format | "auto" | "as_received" | "json" | "chat" | no | How deliveries go out; "auto" picks chat for Slack and Discord URLs and as received otherwise. See Formats |
headers | { name: string; value: string | null }[] | no | Your own headers, at most 10, for relayed and signed JSON deliveries. Replaces the whole list; null keeps the value stored under that name |
appendPath | boolean | no | Add the path after the slug to the URL for relayed requests (default true) |
retrySeconds | 0 | 3600 | 86400 | no | Retry failed deliveries for a day (default), an hour, or not at all |
keepOrder | boolean | no | One delivery at a time, in capture order (default false) |
sentField | string | null | no | A JSON body field with the sender's own timestamp, such as "publishedAt" or "data.publishedAt". See Timing |
/endpoints/:slug/forwardingclient.forwarding.secret
The endpoint's signing secret (whsec_...). Throws NotFoundError until a URL has been saved.
secret(endpointSlug: string): Promise<string>/endpoints/:slug/forwardingclient.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>/endpoints/:slug/forwarding/testclient.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 }/endpoints/:slug/deliveriesclient.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[]>| Param | Type | Required | Description |
|---|---|---|---|
endpointSlug | string | yes | Endpoint slug |
limit | number | no | 1 to 100 (default: 5) |
status | "all" | "pending" | "failed" | no | "pending" is queued or retrying (default: all) |
before | string | no | A row's cursor, to list the rows after it |
/endpoints/:slug/deliveries/summaryclient.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 }/requests/:id/deliveriesclient.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[]>/requests/:id/deliveriesclient.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 }>/endpoints/:slug/deliveries/redeliver-failedclient.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
(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"](local)client.templates.get
Get metadata for a specific provider template.
get(provider: TemplateProvider): TemplateProviderInfoUsage
/usageclient.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.
/teamsclient.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, ... }/teams/:teamId/membersclient.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[] }/teams/:teamId/endpointsclient.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>/teams/:teamId/endpoints/:endpointIdclient.teams.unshare
Stop sharing an endpoint you own with a team. Works on suspended teams too.
unshare(teamId: string, slug: string): Promise<void>/teams/:teamId/inviteclient.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>/invitesclient.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
(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.
/api/agent/identityWebhooksCC.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 refundedSandbox 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.
/api/agent/identity/claimsandbox.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 endpointswaitForClaim() 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.
(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
(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(): SDKDescriptionMatchers
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 messagematchEmail 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.