Documentation
Everything you need to receive, verify, forward and debug webhooks with AS2Expert Relays.
Quickstart
https://relays.as2expert.com/e/rt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx202 immediately, store the raw payload and queue the delivery.Destinations
An endpoint can have several destinations; each webhook creates one delivery per active destination whose filter matches.
HTTP
Any public http(s) URL. We POST the original raw body and headers (hop-by-hop headers removed) plus X-Relay-Signature. A 2xx response marks the delivery ok; anything else is retried. Private, loopback, link-local, CGNAT and cloud-metadata addresses are rejected on every hop, including redirects (maximum 3).
Agent
Delivered through the relay-agent connected for that endpoint (Linux, macOS or Windows). While no agent is connected, deliveries stay pending without consuming attempts.
Local agent
relay-agent is a small open-source program (github.com/as2expert/relay-agent, Apache-2.0) that runs on Linux, macOS and Windows. It opens an outbound TLS connection to our gateway, authenticates with the endpoint's agent key and replays every webhook to a URL on your machine or LAN. No public IP, no port forwarding, no inbound firewall rules.
Install
| Platform | How |
|---|---|
| Linux (x86_64, aarch64) and macOS | One command — downloads the latest release, verifies it against SHA256SUMS and installs to /usr/local/bin (set INSTALL_DIR to change): |
| Windows (x86_64) | Download relay-agent-windows-x86_64.zip, unzip relay-agent.exe into a folder such as C:\Program Files\relay-agent\ and run it from PowerShell. Nothing else to install. |
| Manual | All archives on the releases page: relay-agent-linux-x86_64.tar.gz (static), relay-agent-linux-aarch64.tar.gz (static), relay-agent-macos-universal.tar.gz, relay-agent-windows-x86_64.zip, plus SHA256SUMS. |
| From source | cargo install --git https://github.com/as2expert/relay-agent (Rust 1.85+). |
Run
rak_…). Under Destinations add one of type agent.relay-agent --key rak_… --forward http://localhost:3000/hooks --check
ok: authenticated with the gateway (wss://relays.as2expert.com/agent)relay-agent --key rak_… --forward http://localhost:3000/hooks
INFO relay-agent starting version=0.1.0 gateway=wss://relays.as2expert.com/agent forward=http://localhost:3000/hooks
INFO connected to gateway
INFO forwarded locally delivery_id=… method=POST http_status=200 latency_ms=12On Windows the same, with relay-agent.exe.Options
| Flag | Environment | Default | Meaning |
|---|---|---|---|
--key | RELAY_AGENT_KEY | — | Agent key of the endpoint (rak_…). The public URL token rt_… is rejected. Rotate the key in the dashboard if it leaks. |
--forward | RELAY_FORWARD_URL | — | Local URL that receives the webhooks. Method, headers and raw body are preserved, so provider signatures still verify. |
--gateway | RELAY_GATEWAY_URL | wss://relays.as2expert.com/agent | Only change for a self-hosted relay. |
--timeout | — | 30 | Seconds to wait for your local application. |
--insecure-local-tls | — | off | Accept a self-signed certificate on the local target only (the gateway connection is always verified). |
--json-logs | — | off | Logs as JSON lines for collectors. RUST_LOG=debug for more detail. |
--check | — | — | Connect, authenticate, exit 0 on success or 1 with the rejection reason. |
Run as a service
Ready-to-use examples live in the repository's deploy/ folder:
- Linux (systemd) —
relay-agent.service: putRELAY_AGENT_KEYandRELAY_FORWARD_URLin/etc/relay-agent.env(mode 0600), copy the unit to/etc/systemd/system/, thensystemctl enable --now relay-agent. Runs as a dynamic unprivileged user. - macOS (launchd) —
com.as2expert.relay-agent.plist: copy to~/Library/LaunchAgents/, set the key,launchctl loadit. Starts at login and restarts if it stops. - Windows —
windows.md: a Task Scheduler job at logon (PowerShell snippet included), or a real Windows service with NSSM.
Behaviour
- Offline: while no agent is connected, deliveries wait as pending without consuming attempts and are delivered on reconnect. Nothing is lost.
- Honest log: the agent reports the real HTTP status, latency and response excerpt of your application, visible per event in the dashboard.
- Connection drops mid-delivery are re-queued; make your handler idempotent (use the event id or
X-Relay-Signature). - Reconnection with exponential backoff (1 s → 30 s), keepalive pings every 25 s, clean shutdown on Ctrl-C.
- One agent per endpoint: a new connection replaces the previous one. Rotating the agent key disconnects nothing until the agent reconnects.
- The wire protocol is documented in PROTOCOL.md if you want to write your own client.
Verify our signature
Every delivery (HTTP, agent or alert) carries:
X-Relay-Signature: t=<unix_ts>,v1=<hex HMAC_SHA256(signing_secret, "{t}.{raw_body}")>
The signing_secret (rsec_…) is in the dashboard. Reject signatures older than about five minutes.
Python
import hmac, hashlib, time
def verify(header: str, body: bytes, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
mac = hmac.new(secret.encode(), parts["t"].encode() + b"." + body, hashlib.sha256)
return hmac.compare_digest(mac.hexdigest(), parts["v1"])
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(header, rawBody, secret) {
const p = Object.fromEntries(header.split(",").map(kv => kv.split("=")));
if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false;
const mac = createHmac("sha256", secret).update(`${p.t}.`).update(rawBody).digest("hex");
return timingSafeEqual(Buffer.from(mac), Buffer.from(p.v1));
}
Shell
printf '%s.' "$T" | cat - body.bin | openssl dgst -sha256 -hmac "$RSEC" -hex # compare with v1
Verify the provider
Optionally we check the provider's own signature before storing anything; invalid requests get 401 and never enter the queue. Configure it in Endpoint → Settings → Provider verification:
| Provider | Header checked | Secret |
|---|---|---|
| Stripe | Stripe-Signature (5-minute tolerance) | the endpoint's whsec_… |
| GitHub | X-Hub-Signature-256 | the webhook secret you set on GitHub |
The outcome is shown per event (✓ / ✗). Because we forward the raw body untouched, your application can still verify the provider signature itself.
Provider handshakes
Meta / WhatsApp / Instagram / Messenger
Meta verifies a webhook URL with a GET carrying hub.mode=subscribe, hub.verify_token and hub.challenge. Set the same verify token in Endpoint → Settings → Meta verify token; we answer the challenge for you, and the real notifications arrive as normal POSTs.
Slack (and compatible)
A POST whose body is {"type":"url_verification","challenge":"…"} is answered inline with the challenge and is not stored as an event. Nothing to configure.
Filters
A destination can carry a filter; only matching events create a delivery for it.
{"path": "type", "eq": "payment.paid"} # equality
{"path": "data.object.amount"} # existence
{"path": "items.0.sku", "eq": "ABC"} # numeric segments index arrays
path is a dot-path into the JSON body; eq compares string, number or boolean (numbers compare numerically). A filter against a non-JSON body never matches; no filter delivers everything. Replays re-evaluate filters.
Retries, replay and alerts
- Retries: after a non-2xx or transport error we retry at 1 m, 5 m, 15 m, 1 h, 6 h, 24 h (±20 % jitter), up to 10 attempts, then the delivery is exhausted.
- Replay: from the event page. Events are immutable; a replay creates new deliveries for the current active destinations.
- Alerts: set an alert URL on the endpoint and we POST a signed
delivery.exhaustednotification there:{"type":"delivery.exhausted","delivery_id":"…","event_id":"…","endpoint_id":"…","attempts":10,"last_error":"…","at":"…"} - At-least-once: if a worker dies mid-delivery the attempt is repeated after the lease expires; make your handler idempotent (use
event_id).
Account & security
- Registration requires a valid email (activation link, 24 h) and passes a Cloudflare Turnstile check.
- Passwords are hashed with Argon2id. Forgot it? Request a reset link (valid 60 minutes, single use). Changing the password signs out every other session.
- Sessions are opaque bearer tokens with a 30-day rolling expiry. Sign out from the account page.
- Three independent secrets per endpoint: the public URL token (
rt_), the signing secret (rsec_) and the agent key (rak_). Each can be rotated separately.
Plans and limits
| Plan | Events / month | Endpoints | Retention |
|---|---|---|---|
| Free | 10,000 | 2 | 7 days |
| Starter | 100,000 | 10 | 30 days |
| Pro | 1,000,000 | 50 | 90 days |
| Business | 10,000,000 | unlimited | 1 year |
Payload limit 256 KiB (413 above). Ingest is rate-limited per endpoint (20 req/s sustained, burst 40 → 429) and the monthly quota returns 429 once reached. Usage is on the Usage page.
Billing
Paid plans are billed monthly through Stripe. Nothing is charged until you choose a plan.
- Upgrade: dashboard → Plan & billing → choose a plan. You are taken to Stripe Checkout (card, SEPA and local methods where available; promotion codes accepted). The plan is active as soon as Stripe confirms the payment, usually within seconds.
- Invoices, payment method, VAT number: the Invoices & payment method button opens the Stripe billing portal. Invoices are issued automatically for every payment; add your company details and VAT ID there (or at checkout).
- Change plan: from the portal. Switching between paid plans is prorated by Stripe; the new quotas apply immediately.
- Cancel: from the portal, effective at the end of the current period. The account then returns to the Free plan and its limits; events beyond the free retention are deleted by the normal retention job.
- Failed payment: Stripe retries and emails you; the plan stays active while the subscription is past due. If it is finally cancelled the account drops to Free.
- Refunds and enterprise terms: contact us.
HTTP API
Base URL https://relays.as2expert.com. JSON in and out. Session endpoints use Authorization: Bearer <session token> obtained from /auth/login (or the activation / reset responses).
| Method & path | Purpose |
|---|---|
POST /e/{token} | Ingest a webhook (any content type). 202 {event_id, deliveries}. |
GET /e/{token}?hub.* | Meta-style verification handshake. |
POST /auth/register | {email, password, captcha_token} → 202, activation email sent. |
POST /auth/activate | {token} → session. |
POST /auth/resend-activation | {email, captcha_token} → 202. |
POST /auth/login | {email, password} → session; 403 account_not_activated until activated. |
POST /auth/logout | Revokes the bearer session. |
POST /auth/forgot | {email, captcha_token} → 202 always. |
POST /auth/reset | {token, password} → session; all other sessions revoked. |
GET /api/me · POST /api/me/password | Account; {current_password, new_password}. |
GET /api/me/usage | Plan, quotas and current-month counters. |
GET /api/billing | Current subscription, plans and whether online payment is enabled. |
POST /api/billing/checkout · POST /api/billing/portal | {plan} → Stripe Checkout URL / billing portal URL. |
POST /webhooks/stripe | Stripe → Relays (signed with the webhook secret). |
GET|POST /api/endpoints | List / create {name, alert_url?}. |
PATCH|DELETE /api/endpoints/{id} | Update {name?, alert_url?, hub_verify_token?, verification?: {provider, secret} | null} / delete. |
POST /api/endpoints/{id}/regenerate-token | New public URL token (old one stops immediately). |
POST /api/endpoints/{id}/regenerate-agent-key | New agent key. |
GET|POST /api/endpoints/{id}/destinations | List / create {type: "http"|"agent", url?, filter?}. |
DELETE /api/destinations/{id} | Remove a destination. |
GET /api/endpoints/{id}/events?limit&before | Newest first; use next_before for the next page. |
GET /api/events/{id} · POST /api/events/{id}/replay | Headers, body and delivery log / replay. |
GET /status | Public health: queue depth, oldest pending, last-hour counters. |