Documentation

Everything you need to receive, verify, forward and debug webhooks with AS2Expert Relays.

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 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. 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

PlatformHow
Linux (x86_64, aarch64) and macOSOne command — downloads the latest release, verifies it against SHA256SUMS and installs to /usr/local/bin (set INSTALL_DIR to change):
curl -fsSL https://raw.githubusercontent.com/as2expert/relay-agent/main/install.sh | sh
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.
ManualAll 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 sourcecargo install --git https://github.com/as2expert/relay-agent (Rust 1.85+).

Run

In the dashboard open your endpoint → Agent and copy the agent key (rak_…). Under Destinations add one of type agent.
Check the setup once. The command connects, authenticates and exits; if the key is wrong it prints the reason.
relay-agent --key rak_… --forward http://localhost:3000/hooks --check
ok: authenticated with the gateway (wss://relays.as2expert.com/agent)
Run it for real. Send a test webhook to your endpoint URL and watch it arrive locally.
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=12
On Windows the same, with relay-agent.exe.

Options

FlagEnvironmentDefaultMeaning
--keyRELAY_AGENT_KEYAgent key of the endpoint (rak_…). The public URL token rt_… is rejected. Rotate the key in the dashboard if it leaks.
--forwardRELAY_FORWARD_URLLocal 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.
--timeout30Seconds to wait for your local application.
--insecure-local-tlsoffAccept a self-signed certificate on the local target only (the gateway connection is always verified).
--json-logsoffLogs as JSON lines for collectors. RUST_LOG=debug for more detail.
--checkConnect, 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:

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.

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

Account & security

Plans and limits

PlanEvents / monthEndpointsRetention
Free10,00027 days
Starter100,0001030 days
Pro1,000,0005090 days
Business10,000,000unlimited1 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.

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 /api/me · POST /api/me/passwordAccount; {current_password, new_password}.
GET /api/me/usagePlan, quotas and current-month counters.
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} / 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?}.
DELETE /api/destinations/{id}Remove a destination.
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.