Documentation
Everything you need to receive, verify, forward and debug webhooks with AS2Expert Relays: from the first endpoint to the HTTP API.
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 that you have verified. 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).
Ownership verification
Before an HTTP destination receives anything you must prove you control it — this stops the relay from being pointed at third parties. Each destination gets a token (rvt_…, shown in the dashboard). Prove ownership in one of two ways, then click Verify now (or POST /api/destinations/{id}/verify):
- Echo: we send
POSTto the destination with headerX-Relay-Verification: rvt_…and body{"type":"destination.verification","token":"rvt_…"}; answer2xxwith the token in the response body (anywhere) or in the same response header. - DNS: publish a TXT record on the destination's hostname with the value
relay-verify=rvt_….
Webhooks that arrive while a destination is unverified are stored (they count towards your quota) but not forwarded to it; use Replay once verified. Agent destinations need no verification, since the agent already authenticates with the endpoint's key.
Request rewrite and throttling
Each HTTP destination can adapt the request before it is sent (Destinations → Advanced): add or replace headers (for example an Authorization header your application expects), remove headers, override the HTTP method, and append a path segment or query parameters to the URL. The raw body and our X-Relay-Signature are never modified, and hop-by-hop headers cannot be set.
PATCH /api/destinations/{id}
{"rewrite": {"set_headers": {"Authorization": "Bearer …"}, "remove_headers": ["x-internal"],
"method": "PUT", "append_path": "/stripe", "append_query": "source=relay"},
"max_per_second": 5}
max_per_second caps how fast we deliver to that destination (0.1–1000). When the limit is reached, deliveries wait in the queue without consuming retry attempts; useful for destinations with their own rate limits.
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 and run, per operating system
Pick your platform. In every case you need the endpoint's agent key (rak_…, dashboard → Endpoint → Agent) and a destination of type agent (dashboard → Endpoint → Destinations). Replace http://localhost:3000/hooks with the URL of your application.
Linux (x86_64 and ARM64, including Raspberry Pi)
# 1. Install (downloads the latest release, verifies SHA256SUMS, installs to /usr/local/bin)
curl -fsSL https://raw.githubusercontent.com/as2expert/relay-agent/main/install.sh | sh
# 2. Check the key and the connection once
relay-agent --key rak_… --forward http://localhost:3000/hooks --check
# 3. Run
relay-agent --key rak_… --forward http://localhost:3000/hooks
As a service (systemd), using the unit from the repository:
sudo curl -fsSL https://raw.githubusercontent.com/as2expert/relay-agent/main/deploy/relay-agent.service -o /etc/systemd/system/relay-agent.service
printf 'RELAY_AGENT_KEY=rak_…\nRELAY_FORWARD_URL=http://127.0.0.1:3000/hooks\n' | sudo tee /etc/relay-agent.env >/dev/null
sudo chmod 600 /etc/relay-agent.env
sudo systemctl daemon-reload && sudo systemctl enable --now relay-agent
journalctl -u relay-agent -f
Manual download: relay-agent-linux-x86_64.tar.gz or relay-agent-linux-aarch64.tar.gz (static binaries, no dependencies).
macOS (Apple Silicon and Intel, universal binary)
# 1. Install (Terminal; asks for your password to write /usr/local/bin)
curl -fsSL https://raw.githubusercontent.com/as2expert/relay-agent/main/install.sh | sh
# 2. Check the key and the connection once
relay-agent --key rak_… --forward http://localhost:3000/hooks --check
# 3. Run
relay-agent --key rak_… --forward http://localhost:3000/hooks
Start at login and keep it running (launchd):
curl -fsSL https://raw.githubusercontent.com/as2expert/relay-agent/main/deploy/com.as2expert.relay-agent.plist \
-o ~/Library/LaunchAgents/com.as2expert.relay-agent.plist
# edit the file: put your key in RELAY_AGENT_KEY and your URL after --forward
launchctl load ~/Library/LaunchAgents/com.as2expert.relay-agent.plist
tail -f /tmp/relay-agent.log
Manual download: relay-agent-macos-universal.tar.gz. The binary is not notarized yet: if Gatekeeper blocks it, run xattr -d com.apple.quarantine /usr/local/bin/relay-agent once.
Windows (x86_64, Windows 10/11 and Server)
No installer needed: it is a single relay-agent.exe. In PowerShell:
# 1. Download and unzip
Invoke-WebRequest https://github.com/as2expert/relay-agent/releases/latest/download/relay-agent-windows-x86_64.zip -OutFile relay-agent.zip
Expand-Archive relay-agent.zip -DestinationPath "C:\Program Files\relay-agent" -Force
# 2. Check the key and the connection once
& "C:\Program Files\relay-agent\relay-agent.exe" --key rak_… --forward http://localhost:3000/hooks --check
# 3. Run (leave the window open, or install it as a task/service below)
& "C:\Program Files\relay-agent\relay-agent.exe" --key rak_… --forward http://localhost:3000/hooks
Run automatically at logon with Task Scheduler (restarts if it stops):
[Environment]::SetEnvironmentVariable("RELAY_AGENT_KEY", "rak_…", "User")
$action = New-ScheduledTaskAction -Execute "C:\Program Files\relay-agent\relay-agent.exe" -Argument "--forward http://localhost:3000/hooks"
$trigger = New-ScheduledTaskTrigger -AtLogOn
$settings = New-ScheduledTaskSettingsSet -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit 0
Register-ScheduledTask -TaskName "relay-agent" -Action $action -Trigger $trigger -Settings $settings
Start-ScheduledTask -TaskName "relay-agent"
As a real Windows service (starts without anyone logged in) use NSSM; the exact commands are in deploy/windows.md. Manual download: relay-agent-windows-x86_64.zip. SmartScreen may warn the first time because the binary is not code-signed yet: choose More info → Run anyway.
Docker (any platform with a container runtime)
Multi-arch image (linux/amd64, linux/arm64) built from scratch around the static binary, published at ghcr.io/as2expert/relay-agent:
docker run -d --name relay-agent --restart unless-stopped \
-e RELAY_AGENT_KEY=rak_… \
ghcr.io/as2expert/relay-agent:latest --forward http://host.docker.internal:3000/hooks
# Linux hosts: add --add-host=host.docker.internal:host-gateway, or use the service name in a compose network
A docker-compose.yml example is in the repository README.
Other platforms / from source
Any platform with Rust 1.85+: cargo install --git https://github.com/as2expert/relay-agent. All archives and SHA256SUMS are on the releases page.
Options (all platforms)
| 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. |
Behaviour
- Offline: while no agent is connected, deliveries wait as pending without consuming attempts; the moment the agent connects they are re-queued and delivered immediately. 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.
Response to the provider
By default the endpoint answers 202 Accepted with {"event_id": …, "deliveries": n} as soon as the webhook is stored. Two other modes are available in Endpoint → Settings → Response to the provider:
- Static: a fixed status,
Content-Typeand body, for providers that insist on200 OK, an empty body, or a specific acknowledgement document. - Synchronous (paid plans): the webhook is delivered inline to the endpoint's first HTTP destination and its status, content type and body are returned to the provider, within a timeout of 1 to 10 seconds. If the destination fails or does not answer in time, the static settings (or the default 202) are returned and the delivery continues through the normal retry path. Use it when the provider expects your application's own answer, e.g. a challenge computed from the payload. Agent destinations are not eligible; the response header
X-Relay-Event-Idcarries the event id.
The Meta and Slack handshakes below take precedence over these settings.
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
Each destination has a retry window. After a non-2xx response or a transport error we retry with exponential backoff (±20 % jitter) until the window is exhausted; then the delivery is exhausted and, if configured, an alert is sent.
| Window | Schedule | Attempts | Total | Plans |
|---|---|---|---|---|
short | 10 s, 30 s, 1 m, 2 m, 5 m, 10 m | 7 | ≈ 30 minutes | all |
medium (default) | 1 m, 5 m, 15 m, 1 h, 6 h, then every 24 h | 10 | ≈ 4 days | all |
long | same, then every 24 h | 13 | ≈ 7 days | Starter and up |
max | same, then every 24 h | 36 | ≈ 30 days | Pro and Business |
Choose it per destination when creating it or from the Destinations tab; change it any time (PATCH /api/destinations/{id} {"retry_profile": "long"}). A window beyond your plan returns 403 plan_retry_profile.
- Replay: from the event page. Events are immutable; a replay creates new deliveries for the current active destinations.
- Alert URL (per endpoint): we POST a signed
delivery.exhaustednotification there for every exhausted delivery:{"type":"delivery.exhausted","delivery_id":"…","event_id":"…","endpoint_id":"…","attempts":10,"last_error":"…","at":"…"} - Alert policies (dashboard → Alerts): incident-based notifications by email, Slack, Discord, Microsoft Teams or a webhook. Two triggers: any delivery exhausted its retries, or failure rate above a threshold over a window (with a minimum number of attempts to avoid noise). Policies are evaluated every minute per endpoint; an incident opens once (one notification), stays open while the condition holds, and resolves once a full window passes with successful deliveries (one recovery notification). Channels per plan: Free 1, Starter 3, Pro 10, Business 25. Use Test on a channel to check it.
- 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. - Two-factor authentication (TOTP): enable it in Account → Two-factor authentication with any authenticator app; you get eight single-use recovery codes. Sign-in then asks for a code after the password (
POST /auth/loginanswers{"totp_required": true, "challenge": …}; exchange it withPOST /auth/totp {challenge, code}, valid 5 minutes). Enabling it signs out every other session. Disabling requires the password plus a code or recovery code. - Activity log: sign-ins, sign-outs, password changes, and every change to endpoints, destinations, alerts and billing are recorded with time and IP (Account → Activity, kept for one year).
- Your data: Account → Export my data downloads a JSON file with endpoints, destinations, settings, stored webhooks (up to 5,000 newest), alerts and the activity log.
- Delete account: Account → Delete account, confirmed with your password, cancels any paid subscription immediately and deletes the account with all endpoints, stored webhooks, destinations, alerts and sessions. Only an anonymised activity entry (a hash prefix of the email) remains. You receive a confirmation email.
Plans and limits
| Plan | Events / month | Endpoints | Retention | Max payload | Destinations / endpoint | Ingest rate | Retry window | Sync response |
|---|---|---|---|---|---|---|---|---|
| Free | 100 | 2 | 7 days | 256 KiB | 2 | 10 req/s (burst 20) | up to 4 days | — |
| Starter | 1,000 | 10 | 30 days | 1 MiB | 5 | 20 req/s (burst 40) | up to 7 days | ✓ |
| Pro | 10,000 | 50 | 90 days | 5 MiB | 10 | 50 req/s (burst 100) | up to 30 days | ✓ |
| Business | 100,000 | unlimited | 1 year | 10 MiB | 20 | 100 req/s (burst 200) | up to 30 days | ✓ |
How limits are enforced at the endpoint URL:
- Monthly events: counted atomically when the event is stored; once the quota is reached the provider gets
429 monthly_quota_exceededuntil the next calendar month (UTC) or an upgrade. Nothing is stored for rejected requests, so most providers will retry later. - Payload: requests above the plan's size get
413 payload_too_large; the absolute ceiling is 10 MiB. - Endpoints: creation is refused above the allowance (
403). After a downgrade, the endpoints beyond the new allowance — the newest ones — are paused and answer403 plan_endpoint_limituntil you upgrade again or delete endpoints. - Destinations: per endpoint, refused at creation above the allowance (
403 plan_destination_limit). - Rate: a per-endpoint token bucket at the plan's rate; bursts above it get
429 rate limit exceeded. - Retention: events older than the plan's retention are deleted hourly.
Current usage against the quotas is on the Usage page and in GET /api/me/usage.
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|DELETE /api/me · POST /api/me/password | Account; delete with {password}; {current_password, new_password}. |
GET /api/me/audit?limit&before · GET /api/me/export | Activity log (keyset by id) / full JSON export as attachment. |
GET /api/me/totp · POST /api/me/totp/setup|enable|disable | Two-factor status / start enrolment (secret, otpauth URL, QR) / confirm {code} / turn off {password, code}. |
POST /auth/totp | {challenge, code} → session (second step of sign-in). |
POST /api/destinations/{id}/verify | Run the ownership check (echo or DNS TXT). |
GET /api/me/usage | Plan, quotas and current-month counters. |
GET /api/alerts | Channels, policies and recent incidents. |
POST /api/alerts/channels · DELETE /api/alerts/channels/{id} · POST /api/alerts/channels/{id}/test | {kind: email|slack|discord|teams|webhook, name, target}. |
POST /api/alerts/policies · PATCH|DELETE /api/alerts/policies/{id} | {trigger: exhausted|failure_rate, endpoint_id?, channel_id, window_minutes, threshold_pct, min_events}; PATCH {enabled}. |
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, response?: {mode, status, headers, body, sync_timeout_secs} | 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?, retry_profile?, rewrite?, max_per_second?}. HTTP destinations start unverified (verified_at null, verify_token). |
PATCH|DELETE /api/destinations/{id} | {retry_profile?, rewrite?: {...} | null, max_per_second?: n | null} / remove. |
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. |