On this page

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

bash
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

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 response

JavaScript

javascript
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 response

CLI

bash
rf webhooks create --url https://hooks.example.com/reuseforge --events order.funded,order.delivered,item.version.published

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

bash
rf webhooks test whk_ordersandpub

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

Webhook event types
EventWhen it is sent
item.publishedA public item became available (first public version published or a draft made active).
item.version.publishedA new immutable version of an item was published.
item.updatedItem metadata or price changed, or a version was yanked or restored. data.previous_attributes lists the old values of changed top-level fields.
review.createdA verified buyer reviewed an item you own.
order.createdAn order containing your items (as seller) or created by you (as buyer) was quoted.
order.fundedEscrow for the order was confirmed on the rail. Sellers should deliver now.
order.deliveredEvery item of the order was delivered and the acceptance window started.
order.releasedEscrow was released to the sellers (accepted, window expired or resolved in the seller's favour).
order.refundedOne or more items were refunded to the buyer.
order.disputedThe buyer disputed delivered items and escrow is frozen until an arbiter decides.
project.fundedA crowdfunded project reached its goal and moved to funded.
payout.sentA seller payout, platform fee, refund or revenue-share payout was sent.
verification.completedA verification run finished (passed, failed or errored).
item.retractedA public item was retracted by its owner or a moderator and removed from search.
project.milestone.approvedA milestone was approved by the backers and its share of the goal was credited to the developers.
pingSent 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

http
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=0ed3654be7e9e1f5491f32d2f0e836988c61fde950533a72ef536ae3789264ec

The body is the envelope, at most 256 KiB:

json
{
  "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" } }
}
MemberMeaning
idUnique per event. The same value in ReuseForge-Event-Id. De-duplicate on it.
typeOne 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_atWhen the event happened (RFC 3339 UTC).
data.objectA 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:

  1. Read the raw body bytes before parsing JSON. Re-serialising changes the bytes.
  2. Parse t and every v1.
  3. Reject if t is more than 300 seconds from your clock (replay protection). Every retry is signed afresh with a new t.
  4. Compute the HMAC and compare it to each v1 in constant time. Accept if any matches (during a secret rotation there are two).

Python (SDK)

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

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

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

javascript
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

bash
# 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:

text
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

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

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 by created_at or re-read the resource.
  • Retries. A delivery succeeds on any 2xx within 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 is exhausted; you can send it again with redeliver_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. A 410 Gone response disables it at once. update_webhook with status: enabled turns it back on and resets the counter. Pings do not count.
  • Delivery log. list_webhook_deliveries shows state, status code, latency and a short excerpt of your response, kept 30 days. Filter by state and event_type.
  • Same event, new attempt. A retry or a redelivery carries the same event id and ReuseForge-Event-Id, a new ReuseForge-Delivery-Attempt and 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:

  • https only, 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 accepts http://localhost for 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: