Quickstart
From an API key to a delivery your endpoint verifies and replays, with curl. Every call is described in the API reference.
Before you start
The API lives at https://event-forge.app/api/v1 and speaks JSON. Create an API key on Settings with the scope Full and send it as a bearer token. A Read key calls only GET operations.
export EVENTFORGE_API_KEY="efk_..." # the key from /settings
curl https://event-forge.app/api/v1/projects \
-H "Authorization: Bearer $EVENTFORGE_API_KEY"An error answers {"error": "...", "message": "..."}: branch on error and the status. After 429, wait for the seconds in Retry-After.
Create a project
POST /api/v1/projects
curl https://event-forge.app/api/v1/projects \
-H "Authorization: Bearer $EVENTFORGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Shop"}'The answer:
{
"id": "7f8e9d0c-1b2a-4c3d-8e9f-0a1b2c3d4e5f",
"name": "Shop",
"publicId": "proj_3f9a1c2b7d4e5f60",
"webhookSecret": "whsec_...",
"createdAt": "2026-10-09T12:00:00Z",
"updatedAt": "2026-10-09T12:00:00Z"
}Webhooks for the project go to https://event-forge.app/inbox/ and its publicId. webhookSecret signs the deliveries: an API key sees it only in this answer, so keep it for your endpoint. A signed-in owner can reveal it later on the project page.
export PROJECT_ID="7f8e9d0c-1b2a-4c3d-8e9f-0a1b2c3d4e5f"
export PUBLIC_ID="proj_3f9a1c2b7d4e5f60"
export EVENTFORGE_WEBHOOK_SECRET="whsec_..." # your endpoint needs itCreate a rule
POST /api/v1/projects/{projectId}/rules
curl https://event-forge.app/api/v1/projects/$PROJECT_ID/rules \
-H "Authorization: Bearer $EVENTFORGE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Everything to my app", "targetUrl": "https://example.com/webhooks"}'A rule forwards the webhooks it matches to targetUrl. Without conditionType it matches every webhook (all); header_equals compares a header and path_prefix the path. The target must be a public http or https address: private and loopback addresses are refused, so for a first try use a public request inspector you control.
Send a webhook
POST /inbox/{publicId}
curl https://event-forge.app/inbox/$PUBLIC_ID \
-H "Content-Type: application/json" \
-d '{"type": "order.paid", "orderId": 1042}'{"eventId": "0b6c2d8e-5f1a-4c3b-9d7e-2a1f0c9b8e7d", "message": "Webhook received"}The inbox needs no key: give its URL to the provider that sends your webhooks. It takes JSON, form, XML or other text in UTF-8, up to 5 MB; a binary body gets 400.
What your endpoint receives
The method and the body as the inbox got them, and the headers the sender sent, each with its first value. A delivery leaves out the headers about the connection and the proxies on the way (such as Host, Forwarded, X-Forwarded-*, X-Real-Ip and True-Client-Ip), Cookie, Accept-Encoding, X-Webhook-Signature and X-Webhook-Timestamp. An EventForge API key in a header keeps only its first characters (efk_12345678[redacted]), and a request without Content-Type goes out with application/json. A delivery adds these:
POST /webhooks HTTP/1.1
Host: example.com
Content-Type: application/json
webhook-id: msg_6f9e3c2a-1b4d-4e5f-8a7b-0c1d2e3f4a5b
webhook-timestamp: 1791547200
webhook-signature: v1,...
X-EventForge-Event-ID: 0b6c2d8e-5f1a-4c3b-9d7e-2a1f0c9b8e7d
X-EventForge-Delivery-ID: 3d4e5f6a-7b8c-4d9e-8f0a-1b2c3d4e5f6a
X-EventForge-Attempt: 1
{"type": "order.paid", "orderId": 1042}Answer with a 2xx status. A delivery that fails is retried, as Retries in the API reference describes. A retry carries the same webhook-id, so skip a message you have already handled. If the sender signed its request with Standard Webhooks headers of its own, they arrive as X-Original-Webhook-Id, X-Original-Webhook-Timestamp and X-Original-Webhook-Signature.
Verify the signature
Every delivery is signed as Standard Webhooks describes, with the project's webhookSecret. Verify against the raw body, before any JSON parsing: a body parsed and serialized again does not match. The libraries also refuse a webhook-timestamp more than 5 minutes from your clock, and after a rotation they accept either of the two signatures.
Node.js
// npm install express standardwebhooks
const express = require("express");
const { Webhook } = require("standardwebhooks");
const wh = new Webhook(process.env.EVENTFORGE_WEBHOOK_SECRET); // whsec_...
const app = express();
// express.raw keeps the body as bytes, as sent: the signature covers them.
// The inbox takes up to 5 MB, so the endpoint does too.
app.post("/webhooks", express.raw({ type: "*/*", limit: "5mb" }), (req, res) => {
try {
// jsonParse: false checks the signature only; the body may be any text.
wh.verify(req.body, req.headers, { jsonParse: false });
} catch (err) {
return res.status(400).send("invalid signature");
}
// webhook-id names the message: skip one you have already handled.
console.log("verified", req.headers["webhook-id"], req.headers["content-type"], req.body.length, "bytes");
res.sendStatus(204);
});
app.listen(3000);
Python
# pip install flask standardwebhooks
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["EVENTFORGE_WEBHOOK_SECRET"]) # whsec_...
app = Flask(__name__)
@app.post("/webhooks")
def webhooks():
# get_data() is the body as sent: the signature covers these bytes.
payload = request.get_data()
try:
# json_parse=False checks the signature only; the body may be any text.
wh.verify(payload, dict(request.headers), json_parse=False)
except (WebhookVerificationError, ValueError):
# ValueError: a malformed webhook-signature header.
return "invalid signature", 400
# webhook-id names the message: skip one you have already handled.
print("verified", request.headers["webhook-id"], request.content_type, len(payload), "bytes")
return "", 204
Go
// go get github.com/standard-webhooks/standard-webhooks/libraries
package main
import (
"errors"
"io"
"log"
"net/http"
"os"
standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"
)
func main() {
wh, err := standardwebhooks.NewWebhook(os.Getenv("EVENTFORGE_WEBHOOK_SECRET")) // whsec_...
if err != nil {
log.Fatal(err)
}
http.HandleFunc("POST /webhooks", func(w http.ResponseWriter, r *http.Request) {
// Read the body as sent: the signature covers these bytes. A body over
// 5 MB, the inbox limit, is refused, not cut and half verified.
payload, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 5<<20))
if err != nil {
status := http.StatusBadRequest
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
status = http.StatusRequestEntityTooLarge
}
http.Error(w, "cannot read the body", status)
return
}
if err := wh.Verify(payload, r.Header); err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
// webhook-id names the message: skip one you have already handled.
log.Printf("verified %s: %s, %d bytes", r.Header.Get("webhook-id"), r.Header.Get("Content-Type"), len(payload))
w.WriteHeader(http.StatusNoContent)
})
log.Fatal(http.ListenAndServe(":3000", nil))
}
PHP
<?php
// composer require standard-webhooks/standard-webhooks
require __DIR__ . '/vendor/autoload.php';
$wh = new \StandardWebhooks\Webhook(getenv('EVENTFORGE_WEBHOOK_SECRET')); // whsec_...
// The body as sent: the signature covers these bytes.
$payload = file_get_contents('php://input');
// The library looks headers up in lower case; some servers capitalize them.
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$wh->verify($payload, $headers);
} catch (\Exception $e) {
http_response_code(400);
exit('invalid signature');
}
// webhook-id names the message: skip one you have already handled.
error_log('verified ' . $headers['webhook-id'] . ', ' . strlen($payload) . ' bytes');
http_response_code(204);
In another language, check it yourself, as Signed deliveries in the API reference describes:
key = base64_decode(secret without "whsec_")
expected = base64(HMAC-SHA256(key, webhook-id + "." + webhook-timestamp + "." + body))
valid = some "v1,<signature>" in webhook-signature equals expected (compare in constant time)
and webhook-timestamp is within 5 minutes of your clockInspect and replay
GET /api/v1/projects/{projectId}/events
GET /api/v1/events/{eventId}
export EVENT_ID="0b6c2d8e-5f1a-4c3b-9d7e-2a1f0c9b8e7d"
curl https://event-forge.app/api/v1/projects/$PROJECT_ID/events \
-H "Authorization: Bearer $EVENTFORGE_API_KEY"
curl https://event-forge.app/api/v1/events/$EVENT_ID \
-H "Authorization: Bearer $EVENTFORGE_API_KEY"An event lists its deliveries with status, responseStatus, lastError and messageId, the webhook-id your endpoint got. Replay sends the event to the rules again:
POST /api/v1/events/{eventId}/replay
curl -X POST https://event-forge.app/api/v1/events/$EVENT_ID/replay \
-H "Authorization: Bearer $EVENTFORGE_API_KEY"{"message": "Replay initiated", "deliveriesCreated": 1}A replay is a new message: it arrives with a new webhook-id.
Rotate the secret
POST /api/v1/projects/{projectId}/webhook-secret/rotate
Rotate the secret on the project page, or with this operation from a signed-in session: API keys cannot read or rotate the secret. For 24 hours deliveries carry a signature with the new secret and one with the previous, so update your endpoint within that time. Rotating again stops the older secret at once.