API
Webhooks
Signed event delivery to your server. Event types, the envelope, how to verify the ReuseForge-Signature in Python, JavaScript and curl, retries, secret rotation and URL rules.
A webhook endpoint receives an HTTPS POST for every event you subscribe to, signed so you can tell it came from ReuseForge. Use webhooks instead of polling get_order or list_versions. Manage endpoints with the webhooks:manage scope: create_webhook, list_webhooks, get_webhook, update_webhook, delete_webhook, rotate_webhook_secret, test_webhook, list_webhook_deliveries and redeliver_webhook (see the reference).
Create an endpoint
curl
curl -X POST https://reuseforge.com/api/v1/webhooks \
-H "Authorization: Bearer $REUSEFORGE_TOKEN" -H "Content-Type: application/json" \
-d '{"url": "https://hooks.example.com/reuseforge", "events": ["order.funded", "order.delivered", "item.version.published"], "description": "orders and releases"}'Python
import reuseforge
client = reuseforge.Client()
hook = client.create_webhook(url="https://hooks.example.com/reuseforge",
events=["order.funded", "order.delivered", "item.version.published"], description="orders and releases")
print(hook["id"], hook["secret"]) # the whsec_... secret appears only in this responseJavaScript
import { ReuseForge } from '@reuseforge/sdk';
const client = new ReuseForge();
const hook = await client.createWebhook({ url: 'https://hooks.example.com/reuseforge',
events: ['order.funded', 'order.delivered', 'item.version.published'], description: 'orders and releases' });
console.log(hook.id, hook.secret); // the whsec_... secret appears only in this responseCLI
rf webhooks create --url https://hooks.example.com/reuseforge --events order.funded,order.delivered,item.version.publishedThe response contains secret (whsec_...) once. Store it as a secret in your receiver. events is a list of event types or ["*"] for all. account (an organization handle you administer) puts the endpoint on an organization. The endpoint count per account is limited (see limits.webhook_endpoints_max in GET /api/v1/).
Send a ping to see that your receiver works; it is delivered synchronously and the outcome is the response:
rf webhooks test whk_ordersandpubEvent types
Subscribe to any of these, or to *. Events fan out to the accounts concerned: item events to the item owner (and the owning organization), order events to the buyer and to the sellers (a seller's copy contains only their own lines), project events to the proposer, the developers and the backers, and payout.sent to the recipient. ping is sent only by test_webhook and cannot be subscribed to.
| Event | When it is sent |
|---|---|
item.published | A public item became available (first public version published or a draft made active). |
item.version.published | A new immutable version of an item was published. |
item.updated | Item metadata or price changed, or a version was yanked or restored. data.previous_attributes lists the old values of changed top-level fields. |
review.created | A verified buyer reviewed an item you own. |
order.created | An order containing your items (as seller) or created by you (as buyer) was quoted. |
order.funded | Escrow for the order was confirmed on the rail. Sellers should deliver now. |
order.delivered | Every item of the order was delivered and the acceptance window started. |
order.released | Escrow was released to the sellers (accepted, window expired or resolved in the seller's favour). |
order.refunded | One or more items were refunded to the buyer. |
order.disputed | The buyer disputed delivered items and escrow is frozen until an arbiter decides. |
project.funded | A crowdfunded project reached its goal and moved to funded. |
payout.sent | A seller payout, platform fee, refund or revenue-share payout was sent. |
verification.completed | A verification run finished (passed, failed or errored). |
item.retracted | A public item was retracted by its owner or a moderator and removed from search. |
project.milestone.approved | A milestone was approved by the backers and its share of the goal was credited to the developers. |
ping | Sent by test_webhook and once after a webhook is created. Not a subscribable event type. |
Orders, payouts and projects events fire only where those operations are enabled; see the availability table.
The request
POST /reuseforge HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: ReuseForge-Webhooks/1
ReuseForge-Event-Id: evt_orderfunded2
ReuseForge-Delivery-Id: whd_deliveryok22
ReuseForge-Delivery-Attempt: 1
ReuseForge-Signature: t=1793700012,v1=0ed3654be7e9e1f5491f32d2f0e836988c61fde950533a72ef536ae3789264ecThe body is the envelope, at most 256 KiB:
{
"id": "evt_orderfunded2",
"object": "event",
"type": "order.funded",
"api_version": "1",
"created_at": "2026-11-03T10:07:12Z",
"account": "acme",
"data": { "object": { "id": "ord_tqjv5mw3k2na", "object": "order", "state": "funded" } }
}| Member | Meaning |
|---|---|
id | Unique per event. The same value in ReuseForge-Event-Id. De-duplicate on it. |
type | One of the event types above. Accept unknown types and ignore them: new ones are added without a new API version. |
api_version | "1", fixed when the endpoint is created. Breaking payload changes only appear in a new version that you opt into by creating a new endpoint. |
created_at | When the event happened (RFC 3339 UTC). |
data.object | A snapshot of the resource at event time, so you rarely need to call back. item.updated also carries data.previous_attributes. |
Respond with any 2xx within 10 seconds. Do the heavy work afterwards: store the event, answer, then process. Redirects are not followed; a 3xx counts as a failure.
Verify the signature
ReuseForge-Signature has the form t=<unix seconds>,v1=<hex>[,v1=<hex>]. The signature is the hex HMAC-SHA256 of the string <t>.<raw request body> keyed by the whole whsec_... secret (as UTF-8 bytes). To verify:
- Read the raw body bytes before parsing JSON. Re-serialising changes the bytes.
- Parse
tand everyv1. - Reject if
tis more than 300 seconds from your clock (replay protection). Every retry is signed afresh with a newt. - Compute the HMAC and compare it to each
v1in constant time. Accept if any matches (during a secret rotation there are two).
Python (SDK)
import reuseforge
# raw_body: the request body as bytes, header: the ReuseForge-Signature value, secret: your whsec_... string
event = reuseforge.webhooks.verify(raw_body, header, secret) # tolerance 300 s; raises SignatureError
print(event["type"], event["id"])Python (no SDK)
import hashlib
import hmac
import time
def verify(raw: bytes, header: str, secret: str, tolerance: int = 300) -> None:
pairs = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = int(next(v for k, v in pairs if k == "t"))
if abs(time.time() - t) > tolerance:
raise ValueError("timestamp outside tolerance")
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, v) for k, v in pairs if k == "v1"):
raise ValueError("bad signature")JavaScript (SDK)
import { webhooks } from '@reuseforge/sdk/webhooks';
// rawBody: the body as a string or bytes, never the parsed object
const event = await webhooks.verify(rawBody, request.headers.get('reuseforge-signature'), secret); // throws SignatureError
console.log(event.type, event.id);JavaScript (no SDK)
export async function verify(raw, header, secret, tolerance = 300) {
const pairs = header.split(',').map((p) => p.split('='));
const t = Number(pairs.find(([k]) => k === 't')[1]);
if (Math.abs(Date.now() / 1000 - t) > tolerance) throw new Error('timestamp outside tolerance');
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${t}.${raw}`)));
const hex = [...mac].map((b) => b.toString(16).padStart(2, '0')).join('');
const same = (a, b) => a.length === b.length && a.split('').reduce((x, c, i) => x | (c.charCodeAt(0) ^ b.charCodeAt(i)), 0) === 0;
if (!pairs.some(([k, v]) => k === 'v1' && same(v, hex))) throw new Error('bad signature');
}curl and openssl
# Recompute the signature of a delivery you saved, to compare with the v1 value in the header
t=1793700012
body='{"id":"evt_pingevent222","object":"event","type":"ping"}'
printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac 'whsec_test_secret' | awk '{print $NF}'The (no SDK) JavaScript works in Node 18+, Workers, Deno and Bun (Web Crypto). Compare in constant time as shown; a plain === on the signature leaks timing.
A test vector, so you can check your implementation before you go live:
secret = whsec_test_secret
t = 1793700012
body = {"id":"evt_pingevent222","object":"event","type":"ping"}
v1 = 0ed3654be7e9e1f5491f32d2f0e836988c61fde950533a72ef536ae3789264ec
header = t=1793700012,v1=0ed3654be7e9e1f5491f32d2f0e836988c61fde950533a72ef536ae3789264ec(The timestamp is old, so tolerance checks reject it; the vector is for the HMAC only. The SDKs take a tolerance argument and ship webhooks.sign() to build headers in your tests.)
A complete receiver
Python
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
import reuseforge
SECRET = "whsec_..." # from create_webhook; load it from your secret store
seen = set() # use a database in production
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("content-length", 0)))
try:
event = reuseforge.webhooks.verify(raw, self.headers.get("ReuseForge-Signature", ""), SECRET)
except reuseforge.SignatureError:
self.send_response(400)
self.end_headers()
return
if event["id"] not in seen: # at-least-once delivery: skip repeats
seen.add(event["id"])
print("handling", event["type"], json.dumps(event["data"]["object"])[:80])
self.send_response(200)
self.end_headers()
HTTPServer(("127.0.0.1", 8080), Handler).serve_forever()JavaScript
import http from 'node:http';
import { webhooks } from '@reuseforge/sdk/webhooks';
const SECRET = process.env.REUSEFORGE_WEBHOOK_SECRET; // whsec_...
const seen = new Set(); // use a database in production
http.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const raw = Buffer.concat(chunks); // the raw bytes, before any JSON parsing
try {
const event = await webhooks.verify(raw, req.headers['reuseforge-signature'], SECRET);
if (!seen.has(event.id)) { seen.add(event.id); console.log('handling', event.type); }
res.writeHead(200).end('ok');
} catch {
res.writeHead(400).end('bad signature');
}
}).listen(8080);Delivery guarantees
- At least once, unordered. The same event can arrive more than once and events can arrive out of order. De-duplicate on
id; order bycreated_ator re-read the resource. - Retries. A delivery succeeds on any
2xxwithin 10 seconds. Otherwise it is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and then twice after 24 hours (eight retries, about two days), each with about 10 percent jitter. After the last failure the delivery isexhausted; you can send it again withredeliver_webhook. - Auto-disable. An endpoint with no successful delivery for 72 hours and at least 10 failed attempts becomes
disabled(disabled_reason: consecutive_failures) and the owner is notified. A410 Goneresponse disables it at once.update_webhookwithstatus: enabledturns it back on and resets the counter. Pings do not count. - Delivery log.
list_webhook_deliveriesshows state, status code, latency and a short excerpt of your response, kept 30 days. Filter bystateandevent_type. - Same event, new attempt. A retry or a redelivery carries the same event
idandReuseForge-Event-Id, a newReuseForge-Delivery-Attemptand a fresh signature timestamp.
Rotate the secret
rotate_webhook_secret issues a new secret and keeps the old one valid for 24 hours (previous_secret_expires_at). During that overlap every delivery carries two v1 values, signed with the new secret first and the old one second. Verify against each v1 (the example code above does), deploy the new secret to your receiver, and the old one stops mattering when the overlap ends. The new secret is returned only by this call.
URL rules
The endpoint URL is checked when you create or update it, and again before every attempt:
httpsonly, port 443 or 8443, a DNS name (no IP literals,localhost,.local,.internal), no credentials in the URL, at most 500 characters. A self-hosted node also acceptshttp://localhostfor development.- The host is resolved before each attempt. It is refused when any address is loopback, private (10/8, 172.16/12, 192.168/16), link-local (including the cloud metadata address), shared address space (100.64/10), multicast or reserved, or an IPv6 equivalent.
- Redirects are never followed. Requests carry no cookies and no credentials besides the signature.
A URL that fails the rules is 422 webhook_url_forbidden at creation; at delivery time the attempt fails with ssrf_blocked and a persistent failure disables the endpoint.
Availability
The webhook operations are in the contract and are enabled on reuseforge.com; a self-hosted Python node answers 501 not_implemented for them. The generated availability table shows the current state per deployment.
Last updated: