Skip to content
Browse the docs

Integrating · Published 2026-09-04 · Updated 2026-10-21 · AgentiSend

Webhooks

Signing, verification, the retry schedule, replay, and what happens to a dead letter.

An endpoint subscribes to event types and receives the envelope every event on the spine carries: id, type, occurred_at, account_id and data. The types are in the event catalogue.

An account holds up to 20 endpoints. One endpoint takes a list of event types, so an integration that wants several kinds of event needs one endpoint, not one per type.

What data ties the event to

Every payload says what caused it, so a handler can act without a second request:

EventsAlways in `data`
email.*email_id (the id POST /emails returned), tags (the tags you sent, {} when none), from, to (an array), subject and created_at (when the message was created). A bounce also carries bounce with type, sub_type and diagnostic_code, and recipient when the event is about one address.
limit.warning, limit.exceededapi_key_id (null when the ceiling is the account's plan inclusion), used, budget, and period_started_at in ISO 8601
agent.approval_requested, agent.approved, agent.rejectedaction_id, the id POST /agent-actions/{id}/approve and /reject take

email.received also carries extracted_text and extracted_html: the reply with quoted history and signature lines removed. Either is null when it could not be produced. GET /emails/receiving/{id} still has text and html.

Events about one resource also carry resource_type (email, api_key, agent_action, domain, …) and resource_id. New fields can appear in data at any time, so write handlers that ignore what they do not recognise.

{
  "id": "5f0c…",
  "type": "email.bounced",
  "occurred_at": "2026-09-25T12:09:39.357Z",
  "account_id": "8b1e…",
  "data": {
    "email_id": "3d2a…",
    "tags": { "campaign": "welcome" },
    "resource_type": "email",
    "resource_id": "3d2a…",
    "recipients": ["someone@example.com"],
    "permanent": true
  }
}

A send to a simulator address produces its events like any other send (see how to test without delivering anything), each with "simulated": true in data, so you can exercise a bounce handler without a real bounce.

A body a Resend handler already reads

POST /webhooks takes payload_format. Leave it off, or send native, and the body is the envelope above.

Send payload_format: "resend" and each delivery is the shape a Resend handler reads:

{
  "type": "email.bounced",
  "created_at": "2026-09-25T12:09:39.357Z",
  "data": {
    "email_id": "3d2a…",
    "created_at": "2026-09-25T12:00:00.000Z",
    "from": "Acme <hello@acme.example>",
    "to": ["someone@example.com"],
    "subject": "Your receipt",
    "tags": { "campaign": "welcome" },
    "bounce": {
      "type": "Permanent",
      "subType": "General",
      "diagnosticCode": ["smtp; 550 5.1.1 user unknown"],
      "message": "550 5.1.1 user unknown"
    }
  }
}

type, created_at, data.email_id, data.to, data.bounce.type, data.bounce.subType and data.bounce.diagnosticCode are the fields that handler reads. to is an array. A message that aged out of the queue arrives as email.bounced with bounce.type Transient, not as email.failed.

An endpoint URL must be https. Plain http is refused, because a delivery carries recipient addresses and subjects.

Headers on every delivery

HeaderValue
x-agentisend-signaturet=<unix seconds>,v1=<hex>. HMAC-SHA256 of the signing secret over ` ${t}.${rawBody} . A rotation sends one v1` per secret still in its grace window, and any one of them verifies.
x-agentisend-event-idThe event id. Deduplicate on this.
x-agentisend-event-typeThe event type, such as email.bounced.
x-agentisend-timestampThe same unix seconds as t.
x-agentisend-replaytrue only on a delivery you asked to send again. Absent on a normal delivery.

Reject a t more than 5 minutes from your clock. Without that check a captured delivery can be replayed at you forever. Read the raw body. A framework that parses JSON and writes it out again changes bytes, and the signature was over the bytes that arrived.

The same steps, in one page: How do I verify a webhook signature?.

Verify

These three files are the check. Node calls the function the API uses to sign the delivery. Python and Go compute the same HMAC with the standard library.

/**
 * Verify one AgentiSend delivery with the same function that signs it.
 *
 * Read the raw body before any JSON parser runs. A parser that re-serialises
 * the body changes bytes, and the signature was computed over the bytes that
 * arrived.
 *
 * The examples suite signs a delivery with this package and runs this file
 * against that header and body.
 */
import { SIGNATURE_HEADER, verifyWebhookSignature } from 'agentisend/webhook-signing';

export function acceptDelivery(input: {
  secret: string;
  signature: string;
  rawBody: string;
  nowSeconds?: number;
}): boolean {
  return verifyWebhookSignature(input.secret, input.signature, input.rawBody, {
    nowSeconds: input.nowSeconds,
  });
}

const signature = process.env.WEBHOOK_SIGNATURE;
const rawBody = process.env.WEBHOOK_BODY;
const secret = process.env.WEBHOOK_SECRET;
const now = process.env.WEBHOOK_NOW_SECONDS;

if (signature && rawBody && secret) {
  const ok = acceptDelivery({
    secret,
    signature,
    rawBody,
    ...(now ? { nowSeconds: Number(now) } : {}),
  });
  process.stdout.write(ok ? 'ok\n' : 'rejected\n');
}

export const HEADER = SIGNATURE_HEADER;
examples/webhook-verify-node/verify.ts, run by the test suite against a local AgentiSend server
"""Verify an AgentiSend delivery with the standard library.

HMAC-SHA256 over `<unix seconds>.<raw body>`, header `t=<unix seconds>,v1=<hex>`.
Reject a timestamp more than five minutes off. Compare in constant time.

The examples suite sets WEBHOOK_SECRET, WEBHOOK_SIGNATURE, WEBHOOK_BODY and
WEBHOOK_NOW_SECONDS, then reads `ok` or `rejected`.
"""
import hashlib
import hmac
import os
import sys

TOLERANCE_SECONDS = 5 * 60


def accept_delivery(secret: str, signature: str, raw_body: str, now_seconds: int) -> bool:
    timestamp = None
    provided = []
    for part in signature.split(","):
        key, _, value = part.partition("=")
        if key == "t":
            timestamp = int(value) if value.isdigit() else None
        elif key == "v1" and value:
            provided.append(value)
    if timestamp is None or not provided:
        return False
    if abs(now_seconds - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body}".encode(),
        hashlib.sha256,
    ).hexdigest()
    return any(hmac.compare_digest(expected, item) for item in provided)


def main() -> int:
    secret = os.environ["WEBHOOK_SECRET"]
    signature = os.environ["WEBHOOK_SIGNATURE"]
    raw_body = os.environ["WEBHOOK_BODY"]
    now_seconds = int(os.environ["WEBHOOK_NOW_SECONDS"])
    ok = accept_delivery(secret, signature, raw_body, now_seconds)
    sys.stdout.write("ok\n" if ok else "rejected\n")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/webhook-verify-python/verify.py, run by the test suite against a local AgentiSend server
// Verify an AgentiSend delivery with crypto/hmac.
//
// HMAC-SHA256 over "<unix seconds>.<raw body>", header "t=<unix seconds>,v1=<hex>".
// Reject a timestamp more than five minutes off. Compare in constant time.
//
// The examples suite sets WEBHOOK_SECRET, WEBHOOK_SIGNATURE, WEBHOOK_BODY and
// WEBHOOK_NOW_SECONDS, then reads "ok" or "rejected".
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"os"
	"strconv"
	"strings"
)

const toleranceSeconds = 5 * 60

func acceptDelivery(secret, signature, rawBody string, nowSeconds int64) bool {
	var timestamp int64
	var haveTimestamp bool
	var provided []string
	for _, part := range strings.Split(signature, ",") {
		key, value, ok := strings.Cut(part, "=")
		if !ok {
			continue
		}
		switch key {
		case "t":
			parsed, err := strconv.ParseInt(value, 10, 64)
			if err != nil {
				return false
			}
			timestamp = parsed
			haveTimestamp = true
		case "v1":
			if value != "" {
				provided = append(provided, value)
			}
		}
	}
	if !haveTimestamp || len(provided) == 0 {
		return false
	}
	delta := nowSeconds - timestamp
	if delta < 0 {
		delta = -delta
	}
	if delta > toleranceSeconds {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	_, _ = mac.Write([]byte(fmt.Sprintf("%d.%s", timestamp, rawBody)))
	expected := hex.EncodeToString(mac.Sum(nil))
	matched := false
	for _, item := range provided {
		if hmac.Equal([]byte(expected), []byte(item)) {
			matched = true
		}
	}
	return matched
}

func main() {
	now, err := strconv.ParseInt(os.Getenv("WEBHOOK_NOW_SECONDS"), 10, 64)
	if err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(1)
	}
	ok := acceptDelivery(
		os.Getenv("WEBHOOK_SECRET"),
		os.Getenv("WEBHOOK_SIGNATURE"),
		os.Getenv("WEBHOOK_BODY"),
		now,
	)
	if ok {
		fmt.Println("ok")
	} else {
		fmt.Println("rejected")
	}
}
examples/webhook-verify-go/verify.go, run by the test suite against a local AgentiSend server

Coming from Resend

A call that creates the endpoint with the field endpoint, and does not set svix_compat, turns the extra headers on. A call that uses url leaves them off unless you send svix_compat: true. Either way our five headers above are still sent.

HeaderValue
svix-idThe event id
svix-timestampUnix seconds
svix-signaturev1, then base64 of the HMAC over id.timestamp.body

The signing secret from POST /webhooks is what that verifier takes. The rest of the move, including what differs, is on Migrating from Resend.

Retries

A delivery that does not answer 2xx is attempted up to 10 times: once, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 10 hours, 24 hours, 48 hours, then dead-lettered. The waits:

5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 10 hours, 24 hours, 48 hours

The first seven waits are the ones Resend uses, so a receiver that missed one request while it redeployed hears again after the first wait. The last failure exhausts the schedule rather than scheduling another one.

Answer fast, work later

Return 2xx as soon as you have stored the event. Doing the work inline means a slow consumer looks like a failing one, and a failing one gets retried — which makes it slower.

We wait 10 seconds for an answer and send at most 2 requests to one endpoint at a time, so your events never queue behind another account's slow receiver. An endpoint that fails to answer within 10 seconds three times in a row is paused for 1 minute, then 5, 15 and 60 if it keeps timing out. A paused delivery keeps its place in the retry schedule: the pause does not use up an attempt, and the first answer from your endpoint, even an error, ends it.

Duplicates are expected

At-least-once delivery means the same id can arrive twice: a retry after your 2xx was lost, or a replay you asked for. Deduplicate on the event id.

Dead letters

After the 10th failure the delivery is dead-lettered rather than dropped. The console shows every attempt with its response code and timing, and a dead letter can be replayed once the endpoint is fixed. An endpoint that fails persistently is disabled and the account is notified, so a URL that stopped existing does not quietly become a hole in your event history.

Local development

Point an endpoint at a tunnel, or run the CLI without installing it:

npx @agentisend/sdk-node webhooks listen --secret whsec_… --forward-to http://localhost:3000/webhooks

The same command is in the CLI. The signature headers are forwarded unchanged, byte for byte, so local verification uses the same secret as production.

Without a public URL

An endpoint address has to be https, so an agent with no public address cannot take a delivery. It can still read what already happened.

Email events for one message are GET /emails/{id}/events. The query parameters are limit, cursor and after (after is the same cursor). The list is oldest first. Each row has type, occurred_at, detail and provider_message_id. Call it again with cursor set to the previous next_cursor until has_more is false. The id is the one POST /emails returned, or a row from GET /emails.

The account request log is a different list. GET /logs returns one row per API request (method, path, status, error_code, created_at). It does not return email.delivered. Parameters that exist are since, until, status, status_class, method, route, error_code, api_key_id, source, q, limit, cursor, after and before. since and until are timestamps with an offset. status_class is 2xx, 4xx or 5xx. source is api, console or all. Start with since, then pass cursor from next_cursor, keeping the same since. The read returns at most 500 matching rows, plus one extra row so the page can say whether more exist, and then pages inside that set. A cursor does not walk past that window.

curl -sS -X GET https://api.agentisend.com/emails/9c8f8f0e-3d1a-4d3f-9a1e-2b7c1a0f5e42/events \
  -H "Authorization: Bearer $AGENTISEND_API_KEY"
200
{
  "data": [],
  "has_more": false,
  "next_cursor": "example",
  "object": "list"
}
Response
curl -sS -X GET https://api.agentisend.com/logs \
  -H "Authorization: Bearer $AGENTISEND_API_KEY"
200
{
  "data": [],
  "has_more": false,
  "next_cursor": "example",
  "object": "list"
}
Response

receiving listen is the same loop for received mail. Receiving is open: turn it on for a verified domain, publish the MX record its page shows, and mail to that domain arrives on your account. The command polls GET /emails/receiving and prints one JSON object per line, oldest first. The flags are --limit, --interval, --to and --once. --once prints one page and stops.

The same steps, as a question: How do I get email events without a public webhook URL?.