Documentation

Everything you need to receive, verify, forward and debug webhooks with AS2Expert Relays: from the first endpoint to the HTTP API.

Quickstart

Create an account at /app. We send an activation link to your email; the account is usable once you click it.
Create an endpoint. You get a public URL like
https://relays.as2expert.com/e/rt_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Point your provider at it (Stripe, GitHub, MercadoPago, WhatsApp…). We answer 202 immediately, store the raw payload and queue the delivery.
Add a destination: your HTTPS URL, or the local agent (Linux, macOS or Windows) if your app has no public address.

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):

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)

FlagEnvironmentDefaultMeaning
--keyRELAY_AGENT_KEY—Agent key of the endpoint (rak_…). The public URL token rt_… is rejected. Rotate the key in the dashboard if it leaks.
--forwardRELAY_FORWARD_URL—Local URL that receives the webhooks. Method, headers and raw body are preserved, so provider signatures still verify.
--gatewayRELAY_GATEWAY_URLwss://relays.as2expert.com/agentOnly change for a self-hosted relay.
--timeout—30Seconds to wait for your local application.
--insecure-local-tls—offAccept a self-signed certificate on the local target only (the gateway connection is always verified).
--json-logs—offLogs 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

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:

ProviderHeader checkedSecret
StripeStripe-Signature (5-minute tolerance)the endpoint's whsec_…
GitHubX-Hub-Signature-256the 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:

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.

WindowScheduleAttemptsTotalPlans
short10 s, 30 s, 1 m, 2 m, 5 m, 10 m7≈ 30 minutesall
medium (default)1 m, 5 m, 15 m, 1 h, 6 h, then every 24 h10≈ 4 daysall
longsame, then every 24 h13≈ 7 daysStarter and up
maxsame, then every 24 h36≈ 30 daysPro 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.

Account & security

Plans and limits

PlanEvents / monthEndpointsRetentionMax payloadDestinations / endpointIngest rateRetry windowSync response
Free10027 days256 KiB210 req/s (burst 20)up to 4 days—
Starter1,0001030 days1 MiB520 req/s (burst 40)up to 7 days✓
Pro10,0005090 days5 MiB1050 req/s (burst 100)up to 30 days✓
Business100,000unlimited1 year10 MiB20100 req/s (burst 200)up to 30 days✓

How limits are enforced at the endpoint URL:

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.

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 & pathPurpose
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/logoutRevokes 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/passwordAccount; delete with {password}; {current_password, new_password}.
GET /api/me/audit?limit&before · GET /api/me/exportActivity log (keyset by id) / full JSON export as attachment.
GET /api/me/totp · POST /api/me/totp/setup|enable|disableTwo-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}/verifyRun the ownership check (echo or DNS TXT).
GET /api/me/usagePlan, quotas and current-month counters.
GET /api/alertsChannels, 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/billingCurrent 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/stripeStripe → Relays (signed with the webhook secret).
GET|POST /api/endpointsList / 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-tokenNew public URL token (old one stops immediately).
POST /api/endpoints/{id}/regenerate-agent-keyNew agent key.
GET|POST /api/endpoints/{id}/destinationsList / 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&beforeNewest first; use next_before for the next page.
GET /api/events/{id} · POST /api/events/{id}/replayHeaders, body and delivery log / replay.
GET /statusPublic health: queue depth, oldest pending, last-hour counters.