Skip to content

Forwarding

Send what an endpoint captures to your servers and chat channels: each destination with its own conditions, format (as received, signed JSON, your own JSON or a chat message), timing and log. Retried, logged and timed.

Updated Oct 2026

Forwarding sends what an endpoint captures on to URLs of yours: your servers, Slack or Discord channels, or a no-code tool such as Zapier, Make or n8n (see Zapier and Make). An endpoint can send to up to 10 destinations, and each one decides for itself:

Every delivery is queued the moment the request is captured, sent within a second or two, retried when the destination does not accept it, and logged with its timing, so you can see when the sender sent it, when webhooks.cc received it, and when your server took it. Only the endpoint's owner can change forwarding, and it is available on every plan.

Set it up

  1. Open the endpoint's Settings tab and find Forwarding.
  2. Pick what the destination is for: Your server, Slack or Discord, Your own JSON, Google Sheets, a File drop or an Inbound email handler.
  3. Enter the URL, choose what it gets and how it is sent. Preview with shows the exact request it would send for a request this endpoint captured, before you save.
  4. Click Add destination. It is on at once, and it has a signing secret of its own; copy it into your handler.
  5. Send test delivery sends the newest captured request (or a sample) once and shows what came back.

Requests captured before a destination was added or switched on are not sent to it later; use Deliver now on one of them in its Deliveries tab.

Formats

The format is picked from the URL, and you can change it:

FormatUsed forWhat is sent
As receivedHTTP requests to a server URL (the default)The request as it arrived: same method, headers and body bytes. Emails have no request to relay, so they go as signed JSON.
Signed JSONEmails to a server URL (the default), or bothA JSON envelope with the request or email inside, signed with Standard Webhooks headers.
Your JSONAny URL that wants its own shapeThe JSON you write, with {{ placeholders }} filled in from each request, signed like Signed JSON. See Your own JSON.
Chat messageSlack and Discord incoming webhooks (the default)A readable message: the endpoint, method and path, when it arrived, and up to 3,000 characters of the body; or your own text.
Mail providerEmails to an inbound handler you wrote for Postmark, SendGrid, Mailgun or CloudMailinThe email in that provider's shape. See In a mail provider's shape.

Slack and Discord reject anything but their own JSON, so a URL on hooks.slack.com or discord.com gets a chat message unless you pick otherwise.

As received

An HTTP request is relayed with its method, its headers and its exact body bytes, so a provider's own signature (Stripe-Signature, X-Hub-Signature-256, webhook-signature and the like) still verifies on your server with the provider's secret. Your handler can treat a relayed request as if the provider had sent it to you.

  • The path. The part of the path after the slug is added to your URL: a request to go.webhooks.cc/w/order-events/stripe/events, forwarded to https://api.example.com/hooks, goes to https://api.example.com/hooks/stripe/events. Turn off Append the request path under Advanced to send every request to the URL exactly as written. A path never leaves your URL's path: . and .. segments are dropped.
  • The query string is added as it was sent, after any query your URL has itself.
  • Headers. Every header the sender sent, except the ones that describe a connection rather than the request (Host, Content-Length, Connection, Transfer-Encoding and similar), which are set again for the new connection. Header names arrive in lowercase, and a header sent several times arrives once with its values joined by commas.
  • Redirects are not followed. Answer at the URL itself.

Five headers are added to every relayed request:

HeaderWhat it holds
webhooks-cc-received-atWhen webhooks.cc received the request, RFC 3339 in UTC with milliseconds (2026-10-11T09:30:01.882Z).
webhooks-cc-request-idThe request's id in webhooks.cc. The same on every try and every redelivery of one request.
webhooks-cc-endpointThe endpoint's slug.
webhooks-cc-attemptWhich try this is, from 1.
webhooks-cc-signaturev1, and a signature over the request id, the received-at time and the body, with your secret.

Nothing named webhook-* is added, because Standard Webhooks senders such as Svix, Resend and Clerk use those names themselves, and their signature has to arrive intact.

Check that a relayed request came from webhooks.cc

The provider's signature proves the provider sent it. With the SDK, verifyRelayedRequest() does the check below. To also check that it came through your endpoint, verify webhooks-cc-signature: the base64 HMAC-SHA256, with your secret, of the request id, a dot, the received-at time, a dot, and the raw body bytes, after v1,. The secret starts with whsec_; the key is the base64 after that prefix.

import { createHmac, timingSafeEqual } from "node:crypto";
 
function fromWebhooksCc(rawBody: Buffer, headers: Record<string, string>, secret: string) {
  const id = headers["webhooks-cc-request-id"];
  const receivedAt = headers["webhooks-cc-received-at"];
  const given = (headers["webhooks-cc-signature"] ?? "").replace(/^v1,/, "");
  if (!id || !receivedAt || !given) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${receivedAt}.`)
    .update(rawBody)
    .digest();
  const actual = Buffer.from(given, "base64");
  return actual.length === expected.length && timingSafeEqual(actual, expected);
}
import base64, hashlib, hmac
 
def from_webhooks_cc(raw_body: bytes, headers: dict, secret: str) -> bool:
    request_id = headers.get("webhooks-cc-request-id")
    received_at = headers.get("webhooks-cc-received-at")
    given = headers.get("webhooks-cc-signature", "").removeprefix("v1,")
    if not request_id or not received_at or not given:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{request_id}.{received_at}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return hmac.compare_digest(given, expected)

webhooks-cc-received-at is when the request was captured, so a retry a day later still carries the original time; there is no freshness window to check. To ignore a repeat, remember the webhooks-cc-request-id values you have handled.

Signed JSON

Emails are sent this way to a server URL unless you pick Chat message, and HTTP requests are when you pick Signed JSON, so one handler can take both. The body is a JSON envelope, and the request carries Standard Webhooks headers.

An email

POST /hooks/email HTTP/1.1
Content-Type: application/json
User-Agent: webhooks.cc (+https://webhooks.cc/docs/forwarding)
webhook-id: msg_0b9e5f3a6c1d4f7e9a2b3c4d5e6f7a8b
webhook-timestamp: 1791450602
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
  "type": "email.received",
  "timestamp": "2026-10-08T09:30:01.882Z",
  "data": {
    "id": "0b9e5f3a-6c1d-4f7e-9a2b-3c4d5e6f7a8b",
    "endpoint": { "slug": "acme-signup-flow", "name": "Signup flow" },
    "receivedAt": "2026-10-08T09:30:01.882Z",
    "address": "acme-signup-flow+signup@mailhooks.cc",
    "tag": "signup",
    "subject": "Confirm your email for Tidewater",
    "from": { "name": "Tidewater", "address": "no-reply@tidewater.app" },
    "to": [{ "name": null, "address": "acme-signup-flow+signup@mailhooks.cc" }],
    "cc": [],
    "replyTo": [],
    "date": "2026-10-08T09:30:00.000Z",
    "messageId": "179141283833.2345901@tidewater.app",
    "inReplyTo": [],
    "text": "Hi Ines,\n\nYour confirmation code is 482913. ...",
    "html": "<div style=\"font-family:Arial\">...</div>",
    "replyText": null,
    "fields": { "code": "482913" },
    "codes": ["482913"],
    "links": [{ "url": "https://app.tidewater.app/confirm?token=Zk3q9v", "text": "Confirm email" }],
    "attachments": [
      {
        "filename": "invoice.pdf",
        "contentType": "application/pdf",
        "size": 48211,
        "contentId": null,
        "inline": false
      }
    ],
    "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "tls": "TLSv1_3" },
    "spam": { "score": 1.2, "required": 15, "action": "no action" },
    "headers": { "subject": "Confirm your email for Tidewater", "...": "..." },
    "size": 1485,
    "truncated": { "text": false, "html": false },
    "test": false
  }
}
FieldWhat it holds
typeAlways email.received.
timestampWhen the email arrived. The same on every copy of one email.
data.idThe email's id in webhooks.cc. webhook-id is msg_ followed by this id without its dashes.
data.address, data.tagThe address the email arrived at, and its +tag if it had one.
data.fromThe first From address, or null.
data.text, data.htmlThe text and HTML parts, up to 256 KB each; truncated says when one was cut. text is derived from the HTML when the email has no text part.
data.replyTextThe new text of a reply, without the quoted message and the signature; null when the email quotes nothing. See Reply text.
data.fieldsThe endpoint's named fields, each the text its pattern found or null. Left out when the endpoint has none.
data.codes, data.linksOne-time codes and the links worth clicking, as on the email in the dashboard. Left out when the endpoint turned off "Show codes and links found in emails".
data.attachmentsName, type and size of each attachment, with a signed url or the base64 content as the destination asks; see Attachments.
data.authThe SPF, DKIM and DMARC results (pass, fail, softfail, none, ...) and the TLS version; null when a check did not run.
data.spamrspamd's score, the required score at which it would reject, and its suggested action (never applied); null when the email was not scored.
data.headersEvery header, lowercase names, repeated headers joined with a newline.
data.testtrue for the dashboard's Send test email sample, and for the sample Send test delivery posts while no email has arrived.

The JSON is exactly what each email's JSON tab shows in the dashboard, so you can copy it from there as a test fixture.

An HTTP request

{
  "type": "request.received",
  "timestamp": "2026-10-11T09:30:01.882Z",
  "data": {
    "id": "4c1f8a2e-9b3d-4e6f-8a7b-1c2d3e4f5a6b",
    "endpoint": { "slug": "order-events", "name": "Order events" },
    "receivedAt": "2026-10-11T09:30:01.882Z",
    "method": "POST",
    "path": "/stripe/events",
    "query": "attempt=1",
    "headers": { "content-type": "application/json", "stripe-signature": "t=1791538201,v1=..." },
    "contentType": "application/json",
    "ip": "203.0.113.42",
    "size": 1342,
    "body": "{\"id\":\"evt_1Q...\",\"type\":\"payment_intent.succeeded\"}",
    "bodyBase64": null
  }
}

body is the body as text. When it is not valid UTF-8, body is null and bodyBase64 holds the exact bytes. query is the query string as sent, without the ?.

Verify the signature

Every request carries Standard Webhooks headers. The signature is the base64 HMAC-SHA256, with your secret, of webhook-id, webhook-timestamp and the raw body joined by dots, after v1,. The secret starts with whsec_; the key is the base64 after that prefix.

Check it against the raw request body (before any JSON parsing), compare in constant time, and reject timestamps more than five minutes away from your clock.

For emails, the SDK's verifyForwardedEmail does all of that and returns the parsed email, typed:

import { verifyForwardedEmail, WebhookVerificationError } from "@webhooks-cc/sdk";
 
export async function POST(request: Request) {
  try {
    const event = await verifyForwardedEmail(
      await request.text(),
      request.headers,
      process.env.FORWARD_SECRET!
    );
    await handleInboundEmail(event.data); // your code
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      const status = error.code === "invalid_payload" ? 400 : 401;
      return new Response(error.message, { status });
    }
    throw error;
  }
}

It takes the headers as a Fetch Headers, a plain object such as Node's or Express's req.headers, or name and value pairs, and the body as a string, Uint8Array (a Node Buffer works) or ArrayBuffer. It throws WebhookVerificationError with a code: missing_headers, timestamp_out_of_range (more than 300 seconds off; change it with toleranceSeconds), invalid_signature, or invalid_payload when the body is not an email.received event. See Forwarded emails for an Express handler.

For either kind, in Node without dependencies:

import { createHmac, timingSafeEqual } from "node:crypto";
 
function verify(rawBody: string, headers: Record<string, string>, secret: string): boolean {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (!id || !timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  return (headers["webhook-signature"] ?? "").split(" ").some((part) => {
    const given = Buffer.from(part.replace(/^v1,/, ""), "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}

In Python:

import base64, hashlib, hmac, time
 
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    msg_id, timestamp = headers.get("webhook-id"), headers.get("webhook-timestamp")
    if not msg_id or not timestamp or abs(time.time() - int(timestamp)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(part.removeprefix("v1,"), expected)
        for part in headers.get("webhook-signature", "").split()
    )

The official Standard Webhooks libraries (Go, Python, Ruby, Java, PHP, Rust, C# and more) verify these headers too.

Every copy of one request has the same webhook-id, so a handler that saw it before can skip it.

Rotate the secret in Settings if it leaks. Deliveries are signed with the new secret at once, both formats, so update your handler right away.

Your own JSON

Pick Your JSON and write the body you want. Every string can hold {{ placeholders }}, filled in from each request, so you send only the fields your tool needs, named the way it wants them:

{
  "event": "{{ json.type }}",
  "customer": "{{ json.data.object.customer }}",
  "amount": "{{ json.data.object.amount_paid }}",
  "received": "{{ receivedAt }}"
}

A string that is only a placeholder keeps the value's own type, so "{{ json.data.object.amount_paid }}" becomes the number 4200, and a field the request does not have becomes null. Placeholders inside longer text are written as text. The body is signed with Standard Webhooks headers like the Signed JSON format, so verifyForwardedJson() from the SDK checks it and returns the body, and your own headers are added.

An HTTP request reads method, path, query.page, headers.user-agent (any case), body (the text), json.data.id (a field of the JSON or form body), contentType, ip, size, id, receivedAt, endpoint.slug and signature (verified, failed or missing). An email reads the fields of its email JSON: subject, from.address, from.name, to.0.address, text, replyText, html, tag, your named fields as fields.invoice, codes.0 and links.0.url (when the endpoint shows codes and links), auth.spf, spam.score and the rest. Both read uuid (new for each delivery) and now. Filters work as in mock responses: | default: "none", | upper, | lower, | truncate: 80 and | json.

Insert field next to the template lists what the destination's kinds can read, your named fields included, and the editor refuses a placeholder neither kind has. Use it to send only part of an email: a template with the subject, the sender and the invoice number you named sends nothing else, which is what an email parser does.

In a mail provider's shape

If your app already receives email through an inbound mail service, a destination can post each captured email the way that service would, so the handler you wrote for it runs against real mail from real clients, with their quoting, encodings and headers. Pick Mail provider (or the Inbound email handler preset) and the provider:

ShapeWhat your handler receives
Postmark inboundPostmark's JSON: From, FromFull, ToFull (with each MailboxHash, the +tag), Subject, TextBody, HtmlBody, StrippedTextReply, MailboxHash and Headers, with spam results as X-Spam-* headers.
SendGrid Inbound ParseA multipart/form-data form: headers, dkim, to, from, text, html, sender_ip, spam_score, envelope, charsets, SPF and subject.
Mailgun routeA form like a route's forward(): recipient, sender, from, subject, body-plain, body-html, stripped-text, stripped-signature, message-headers, and timestamp, token and signature.
CloudMailin JSONCloudMailin's JSON Normalized format: envelope (with spf and tls), headers, plain, html and reply_plain.

The Mailgun form is signed as Mailgun signs it when you enter your Mailgun signing key (the HTTP webhook signing key under API Security in Mailgun): signature is the hex HMAC-SHA256 of timestamp and token, so your handler's check passes; without a key it is empty. The key is stored encrypted and never shown again. Every shape also carries this destination's Standard Webhooks headers, so a handler can check that a delivery came from here.

What differs from the real thing: attachments are left out of these shapes (the email JSON carries them, as links or inline); the text and HTML are what webhooks.cc kept (up to 256 KB each); and fields a provider computes for itself (Postmark's MessageID, SendGrid's SpamAssassin report) come from webhooks.cc's own checks or the request id. HTTP requests to such a destination go as received. To try a shape on one email, use whk tunnel --email-format postmark on localhost, client.emails.render() in the SDK, or render_email in the MCP server.

Attachments

A destination that takes emails sends their attachments too. Under Advanced, Email attachments says how:

  • Links (the default): each attachment in the JSON gets a url, a signed link that downloads it for as long as the email is kept. Anyone holding the link can download that one attachment, so treat it like the email itself.
  • Inline: each attachment's content, base64, while the attachments of one delivery add up to 1 MB; larger ones get a url instead.
  • Names only: the name, type and size, as before attachments were kept.
"attachments": [
  {
    "filename": "orders.csv",
    "contentType": "text/csv",
    "size": 2048,
    "contentId": null,
    "inline": false,
    "content": "c2t1LHF0eQpBLTEsMgo="
  },
  {
    "filename": "manual.pdf",
    "contentType": "application/pdf",
    "size": 4194304,
    "contentId": null,
    "inline": false,
    "url": "https://webhooks.cc/api/attachments/6f1c.../1?e=1801234567&s=..."
  }
]

The Postmark shape sends inline contents as Attachments[].Content and CloudMailin's as attachments[].content or attachments[].url; the SendGrid and Mailgun forms list no attachments. Templates read them as {{ attachments.0.url }}. An endpoint that deletes requests after delivery deletes the attachments with them, so use Inline there: a link stops working once the email is gone.

Google Sheets

A Google Sheets destination adds a row to a spreadsheet for each email or request it takes.

  1. Add a destination and pick Google Sheets, then Connect a Google account. webhooks.cc asks only for access to the spreadsheets it creates or that you pick for it (Google's drive.file permission), never the rest of your Drive.
  2. Create it makes a new spreadsheet with your columns as its header row; Choose from Drive opens Google's picker in a small window to pick one you have (allow pop-ups for the site if your browser blocks it). Picking a spreadsheet is what gives webhooks.cc access to it.
  3. Set the columns: each has a header and a value written with the same placeholders as templates, such as {{ subject }}, {{ from.address }} or one of your fields, {{ fields.order }}. Write these headers to row 1 puts the headers into a spreadsheet you picked; webhooks.cc never changes your header row on its own.
  4. One per email adds a row for each email; One per line item adds a row for each line item a layout found, with {{ item.sku }} and the like for its values (an email without line items adds one row).

Rows are written as plain text, so a value that starts with = stays text and never becomes a formula. Rows that arrive close together are added in one call, and a delivery Google refuses is retried like any other; if you disconnect the account or remove webhooks.cc's access in Google, connect it again and the waiting rows go through.

An order email as a row per email and as a row per line item: Email to Google Sheets on mailhooks.cc.

File drops

A File drop writes what the endpoint captures as files to your own server, for systems that watch a folder: an ERP or accounting import, a warehouse system, a nightly job. It is part of Business (and Team Business for endpoints shared with the team).

  1. Add a destination and pick File drop.
  2. Choose where the files go:
    • SFTP: host, port (22), username, and a password or a private key (OpenSSH or PEM). The server's host key is pinned on the first connection and shown on the drop; a different key afterwards stops the drop, as ssh would. Forget it accepts the next one.
    • FTPS (explicit TLS) or FTP: host, port (21), username and password. Plain FTP sends the password and the files unencrypted, so use it only where nothing else is offered.
    • S3: any S3-compatible storage (AWS, Cloudflare R2, Hetzner, MinIO, Backblaze and the like): the endpoint URL, region, bucket, an access key ID and its secret key. The key only needs permission to write objects.
    • Folder (or the key prefix for S3) is where files go; on SFTP and FTP it must exist and is relative to the login's home folder.
    • For a self-signed certificate (FTPS, or S3 on your own server), enter its SHA-256 fingerprint: it is then the only certificate accepted.
  3. Choose what each file holds:
    • CSV or XML: a row per email, or per line item, with the columns you set, each a header and a value written with the same placeholders as templates, as for Google Sheets. In XML each row is a <row> element with an element per column, named after its header.
    • JSON: each email as Signed JSON sends it (HTTP requests as their request JSON), with its parsed fields and line items.
  4. A file per email, or A file per batch: everything that arrives within the minutes you set (1 to 60) after the first email of a batch goes in one file.
  5. Save, then Test the connection: it logs in and writes webhooks-cc-test.txt to the folder.

Files are named after the time and the first request's id, such as orders-20261029-173326-3f2a9c1e.csv (with -batch for a batch, and your prefix in front), and written under a temporary name first, then renamed, so a system watching the folder never picks up half a file. A retry writes the same name again and replaces its own file rather than adding a second.

The password, key or secret key is stored encrypted and never shown again; changing the server, port, username, endpoint or access key asks for it again. Drops connect from our mail server's address, mx.mailhooks.cc, so allow that address if your server only takes known ones. A drop to a server that refuses the login, or to a folder that is not there, fails at once and is not retried; one that cannot be reached is retried like any delivery. On a plan without file drops, a drop is kept but skipped until the plan has them again.

What a CSV and an XML file look like for an order email: File drops on mailhooks.cc.

Send only some captures

Add conditions under What it gets to send only the requests that match, with all or any of them. They work like response rule conditions (method, path, a header, a query parameter, a body field, body text, content type, size), and also read:

FieldReads
Kindhttp or email
Emailits subject, sender, recipients (To and Cc, any of them), text or tag
Email fieldone of the endpoint's named fields, as text or a number
Spam scorethe rspamd score, as a number
Sender checkthe SPF, DKIM or DMARC result: pass, fail, softfail, neutral, none, ...
Signaturethe endpoint's signature check: verified, failed or missing

A request the conditions turn down is not sent, and the log lists it as Filtered out. A condition on the signature waits up to 30 seconds for the check to finish before deciding. Forwarding only verified requests to your server, or only emails whose subject contains "invoice" to your accounting tool, is one condition each.

Chat messages

For a Slack or Discord incoming webhook, each request becomes a message like this:

New webhook on *order-events* (`POST /stripe/events`)
Received 2026-10-11T09:30:01.882Z (UTC), 197 ms after publishedAt
{"id":"evt_1Q...","type":"payment_intent.succeeded", ...}

The body follows in a code block, up to 3,000 characters in Slack and as much as fits in Discord's 2,000. An email reads "New email to ..." with its subject and text. Write your own Message instead, with placeholders: Invoice {{ json.data.object.id }} paid by {{ json.data.object.customer_email }}. It is escaped for Slack, and @everyone in a payload pings nobody on Discord.

Slack takes about one message a second per webhook and Discord a few every two seconds, so messages to one of them are spaced out to stay under that, and when either answers 429 nothing more goes to it until the time it asked for. Messages arrive in capture order only with Keep order on (see Delivery and retries).

A heads-up channel

Under Advanced, At most one every limits a destination to one delivery every so many seconds; the requests in between are not sent. With conditions, only the requests that match count, so a request they turn down never takes the place of one they would send. A destination that also does not retry is a heads-up: a delivery that could not go out within a minute (while the destination was answering slowly, say) is dropped and logged as Filtered out, rather than arriving late in a burst. At 1 second, a Slack or Discord destination becomes a heads-up that something arrived, which is what notifications are: an endpoint's notification URL is a chat destination like this, named Notifications.

Callbacks: send it later

Wait before sending, under Advanced, holds each delivery for up to an hour after the request arrives. With your own JSON it makes a destination behave like a service that answers at once and calls back later: answer the sender with a mock response, and post the result to the callback URL 30 seconds after.

Your own headers

Add up to 10 headers to every delivery, for example the Authorization your server expects. They are added to relayed requests and signed JSON, not to chat messages, and replace a header of the same name the sender sent. Values are stored encrypted and shown masked after saving; to change one, replace it.

Names up to 64 characters, values up to 1,024 characters on one line. These names cannot be added, because the delivery or webhooks.cc sets them: Host, Content-Length, Content-Type, Transfer-Encoding, Connection, Upgrade, TE, Trailer, Keep-Alive, Expect, Proxy-*, webhook-*, webhooks-cc-*, X-Target-URL, X-Auth, X-Proxy-Mode, X-Sender-IP and CF-* (Cloudflare sets those on the way out).

Timing

Each delivery records three moments: when the sender says it sent the request, when webhooks.cc received it, and when your server accepted it. The Deliveries tab of a request shows them on one line, with the time between each to the millisecond, and the delivery log lists the sender-to-receipt time of every request.

webhooks.cc measures the receipt itself: the time the receiver handled the request, after our CDN edge. The sender's time is read from the request:

  • A field in the body. Under Advanced, set Sent time field to the name of a field in the JSON body that holds when the sender sent it, such as publishedAt, or data.publishedAt for a nested one. ISO 8601 times (2026-10-11T09:30:01.685Z) and numbers of seconds or milliseconds since 1970 both work. This is the precise source, to the millisecond if the sender writes milliseconds.
  • A timestamp header, when there is no field or the body does not have it: Standard Webhooks' webhook-timestamp, Svix's svix-timestamp, the t= in Stripe's Stripe-Signature, and Slack's X-Slack-Request-Timestamp. These have whole seconds only, so a lag under a second shows as "within 1 s".
  • An email's Date header, for emails.

The time between the sender's clock and ours includes the network and any queue on the sender's side, and it is only as exact as the two clocks agree: a negative value means the sender's clock is ahead. Chat messages add the same figure after the received time ("197 ms after publishedAt").

Delivery and retries

  • Answer with any 2xx status to accept a delivery. Redirects are not followed and count as failures.
  • Your server has 15 seconds to answer.
  • A failed delivery is tried again after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours, about a day in all, and then marked failed. Under Advanced, Retry failed deliveries can stop after an hour (the first four retries) or send each request only once. A 429 waits at least as long as its Retry-After asks, up to an hour, and counts as a try like any other failure.
  • Deliveries are at least once. Without Keep order, up to two requests are delivered to a destination at a time, so a slow answer does not hold the rest back, but two may overtake each other; webhooks-cc-received-at gives the capture order. With Keep order on, one request is delivered at a time, in the order captured, and a request waiting for its retry holds back the ones after it.
  • Each destination has its own queue, retries and settings: a destination that is down never holds back another.
  • A retry goes to the URL and uses the settings saved at that moment, so fixing a wrong URL or header also fixes the deliveries still waiting. Switching a destination off, taking a kind away from it, or removing it marks its waiting deliveries as failed.
  • At most 1,000 deliveries wait for a destination at a time. A request captured while 1,000 are waiting (a destination down for long under heavy traffic) is not sent to it, and the log says so; redeliver it once the queue has drained.

The delivery log

Settings, Deliveries lists every delivery of the endpoint, newest first, with its destination, status, your server's answer, how long after capture it was delivered, how long your server took, and the sender-to-receipt time when the request has one. Filter it to one destination, to the deliveries still retrying, the ones that failed, or the ones its conditions filtered out; Redeliver all failed sends every failed request again, oldest first, with each destination's current settings, up to 1,000 at a time per destination. Each destination's row in Forwarding shows its last 24 hours.

Open a delivery for its request's Deliveries tab: each destination with every try (the status, the duration, the error or the start of the response) and Redeliver, which sends the request to that destination again with its current settings. On a request its conditions filtered out, Send anyway sends it once regardless. Team members can see a request's deliveries, with the destination's host but not its path; only the owner can redeliver and change forwarding. Deliveries are kept as long as the request itself (your plan's retention).

Test an email handler locally

Forwarding needs a public https URL, but your handler can be tested on your own machine first. client.emails.toJson() builds the JSON forwarding would post for a captured email, and client.sendTo() posts it signed with Standard Webhooks headers and your secret, straight from your machine:

import { WebhooksCC } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
// Any captured email works; send one with client.emails.sendTest("my-endpoint")
const email = await client.emails.latest("my-endpoint");
if (!email) throw new Error("No email captured yet");
 
const res = await client.sendTo("http://localhost:3000/hooks/email", {
  provider: "standard-webhooks",
  secret: process.env.FORWARD_SECRET!,
  body: client.emails.toJson(email, { endpoint: { slug: "my-endpoint", name: "Signup flow" } }),
});
console.log(res.status); // 204

verifyForwardedEmail accepts these requests like real deliveries: the body is the same JSON and the timestamp is the current time. Two differences: the webhook-id is random rather than msg_ followed by the email's id, and nothing is retried. Pass endpoint so data.endpoint.name is filled in (without it the slug is read from the address and the name is null), and includeExtracts: false to leave out codes and links the way forwarding does when the endpoint turned them off.

For a handler written for a mail provider, client.emails.toInbound(email, "postmark") builds the email in that provider's shape (sendgrid, mailgun with your signing key, cloudmailin) to post with its contentType. whk tunnel <port> posts every email the endpoint receives to your machine as this JSON, or in a provider's shape with --email-format, while you click through a signup by hand.

To test the deployed handler through webhooks.cc instead, use Send test delivery, or client.destinations.test().

Manage forwarding from code

The SDK's client.destinations methods do everything the Forwarding section does, client.forwarding the Deliveries log, and the MCP server has the same as tools, so an agent can set up and check forwarding for you. They are for the endpoint's owner only: for anyone else they throw NotFoundError.

import { WebhooksCC } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
const slug = "my-endpoint";
 
// Your server: every request as received, with a token.
const server = await client.destinations.create(slug, {
  url: "https://example.com/hooks",
  headers: [{ name: "Authorization", value: `Bearer ${process.env.HOOK_TOKEN}` }],
  sentField: "publishedAt",
});
const secret = await client.destinations.secret(slug, server.id); // "whsec_...", for your handler
 
// Only paid invoices, as your own JSON, to an automation tool.
const zap = await client.destinations.create(slug, {
  url: "https://hooks.zapier.com/hooks/catch/123/abc",
  http: true,
  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 }}" },
});
 
// What would it send for the newest request? Nothing is sent.
const preview = await client.destinations.preview(slug, { destinationId: zap.id });
console.log(preview.filterPasses, preview.delivery?.body);
 
// Send the newest request (or a sample) once and see what the server answered.
const result = await client.destinations.test(slug, server.id, { kind: "http" });
console.log(result.delivered, result.status, result.excerpt);
 
// The log, by destination, and redeliveries.
const failed = await client.forwarding.deliveries(slug, { status: "failed", destinationId: server.id });
if (failed.length > 0) await client.forwarding.redeliver(failed[0].requestId, { destinationId: server.id });
await client.forwarding.redeliverFailed(slug);
 
// Off, then gone.
await client.destinations.update(slug, zap.id, { enabled: false });
await client.destinations.delete(slug, zap.id);

update() changes only the fields you pass. client.forwarding.configure(), secret() and test() still work on the endpoint's primary destination, the one its forwardUrl stands for. In your handler, verifyRelayedRequest(), verifyForwardedRequest() and verifyForwardedEmail() check deliveries (Forwarded requests); verifyForwardedJson() checks a delivery of your own JSON and returns its body. See the SDK reference for every method and type.

Replay

After fixing a field's pattern or a destination, send captures through the destinations again: Replay these emails on an open alert sends what arrived since it opened, Redeliver on a request sends that one, and the API takes a time range (POST /api/endpoints/{slug}/replay with since and until in milliseconds, at most 500 requests, oldest first). Replayed deliveries use each destination's current settings and the email's fields as they read now. On an endpoint with layouts, a replayed email is parsed again with the current layouts first (attachments parsed before are not charged again).

Alerts

An endpoint can tell you when something stops working. Under Settings, Alerts:

  • Misses in a row: this many emails in a row missed one of the endpoint's fields (all of them, or the ones you mark as required). On an endpoint with layouts, an email no layout matched is a miss too, and the matched layout's fields count as the endpoint's.
  • Share missed: this share of the last 20 emails missed one. Useful when a sender changes its layout for some mail only.
  • Quiet for: no email arrived for this many hours, for an endpoint that should hear from a device or a system regularly.
  • Failed deliveries: a delivery failed after every retry; one alert per destination.

Alerts are checked about once a minute. You get one notice when an alert opens and one when it resolves: an email from webhooks.cc to your account's address (never from the capture domain), and, if you choose, a message to any of the endpoint's destinations: a chat message for Slack or Discord, otherwise a signed alert.opened or alert.resolved JSON event. Alerts carry field names and delivery errors, never an email's contents. Alerts are on every plan.

Limits

  • The URL must be https on a public host name: no IP addresses, localhost or private networks, and not a mail, database or other internal port. On webhooks.cc, deliveries come from Cloudflare's network, not from a fixed address, so do not allowlist IPs; check the signature instead.
  • An endpoint has at most 10 destinations, and none of them can be a webhooks.cc capture URL (go.webhooks.cc): each delivery would be captured and forwarded again.
  • Forwarding does not use your request quota: each request already counted once when it arrived.
  • A request or email whose delivery would be larger than 10 MB is not forwarded. Email text and HTML are kept up to 256 KB each, so real mail stays far below that.

Notifications

Notifications are a destination now: a chat message, at most one a second, not retried, and logged like any other. Set a notification URL through the API and it appears in Forwarding as a destination named Notifications; change it there like any other.