Docs/Developer API/Outbound Webhooks

Outbound Webhooks

Receive HTTP POSTs from Storra when events happen so your systems stay in sync.

Outbound webhooks let Storra push events to your systems in real-time. Use them to:

  • Sync orders into your accounting / CRM (QuickBooks, HubSpot)
  • Post purchase notifications to a Discord channel or Slack workspace
  • Trigger Zapier / Make / n8n automations
  • Update internal databases when refunds happen
  • Track player attribution / conversion events
Webhooks vs pollingIf you don't need real-time updates, you can poll the Checkout API for new orders on a schedule. Webhooks are more efficient and lower latency, but require a publicly-accessible HTTPS endpoint on your side.

Enable Developer mode

Webhooks live behind the Storra Developer app:

  1. Go to Apps.
  2. Find Storra Developer and click Install (or Enable if previously installed).
  3. A new Developer nav group appears in the sidebar with Webhooks, API Keys, and Logs.

Create a webhook

  1. Go to Webhooks and click New webhook.
  2. Endpoint URL: a publicly-accessible HTTPS URL on your server (e.g. https://api.your-server.com/storra-webhook). HTTP is rejected — must be HTTPS.
  3. Description: a short label so you remember what this is for.
  4. Events: tick the event types you want delivered. Subscribe only to what you actually need — every event you don't subscribe to is one fewer request hitting your server.
  5. Click Create. Storra generates a signing secret and shows it once. Copy it into your server's environment variables (STORRA_WEBHOOK_SECRET).

Event types

EventWhen it fires
customer.createdFirst time a unique email buys from your store
purchase.completedOrder paid + recorded; fires after payment confirmation but before deliveries
purchase.refundedRefund processed (check data.partial for partial refunds)
delivery.successA deliverable (game command / Discord role / file) completed
delivery.failedA deliverable dead-lettered after retry exhaustion
basket.abandonedBasket sat untouched for 24h with at least one item
subscription.createdFuture Recurring subscription started
subscription.canceledFuture Subscription canceled
chargeback.createdStripe / PayPal raised a dispute

Request format

Every webhook is a POST with JSON body and these headers:

POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Storra-Webhooks/1.0
X-Storra-Event: purchase.completed
X-Storra-Event-Id: evt_01HXYZ...
X-Storra-Signature: 2f7a... (64 hex chars)
X-Storra-Timestamp: 1745678900

Body envelope:

{
  "id": "evt_01HXYZ...",
  "type": "purchase.completed",
  "created_at": "2026-04-28T10:30:00Z",
  "project_id": "proj_abc",
  "data": {
    "order_id": "order_xyz",
    "customer_email": "buyer@example.com",
    "amount_cents": 999,
    "currency": "USD",
    "items": [...]
  }
}

Verifying signatures (REQUIRED)

Always verify the X-Storra-Signature header before trusting a payload. Skipping verification means anyone who guesses your endpoint URL can forge fake events into your system.

Node.js

import crypto from "node:crypto";
import express from "express";

const app = express();

// Important: capture the raw body BEFORE JSON parsing
app.post("/storra-webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.headers["x-storra-signature"];
    const timestamp = req.headers["x-storra-timestamp"];

    // Reject events older than 5 minutes (replay protection)
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
      return res.status(400).send("timestamp out of range");
    }

    const expected = crypto
      .createHmac("sha256", process.env.STORRA_WEBHOOK_SECRET)
      .update(`${timestamp}.${req.body}`)
      .digest("hex");

    if (!crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(signature, "hex"),
    )) {
      return res.status(401).send("invalid signature");
    }

    const event = JSON.parse(req.body);
    handleEvent(event);
    return res.status(200).send("ok");
  }
);

Python

import hmac, hashlib, time, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["STORRA_WEBHOOK_SECRET"].encode()

@app.post("/storra-webhook")
def webhook():
    sig = request.headers.get("X-Storra-Signature", "")
    ts = request.headers.get("X-Storra-Timestamp", "0")
    if abs(time.time() - int(ts)) > 300:
        abort(400)
    expected = hmac.new(
        SECRET,
        f"{ts}.{request.data.decode()}".encode(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)
    handle_event(request.json)
    return "ok"
Use timing-safe comparisonUse crypto.timingSafeEqual / hmac.compare_digest, never ===. Naive string comparison leaks the secret one byte at a time via timing side-channel.

Retries

If your endpoint returns non-2xx (or doesn't respond within 15 seconds), Storra retries with exponential backoff:

AttemptDelay after previous
1 (initial)
230 seconds
31 minute
42 minutes
55 minutes
610 minutes
730 minutes
8 (final)1 hour

After 8 attempts (~ 1h 50m total), the event is dead-lettered and visible in the dashboard's webhook logs with a "Failed" badge. You can manually replay dead-lettered events from the log.

Idempotency

Make your handler idempotent — the same event might be delivered more than once (network glitches, retry races, or you manually replay from logs). Use X-Storra-Event-Id as a deduplication key:

const event = JSON.parse(req.body);
const seen = await db.eventLog.findUnique({ where: { id: event.id } });
if (seen) return res.status(200).send("already processed");

await db.eventLog.create({ data: { id: event.id } });
await processEvent(event);
res.status(200).send("ok");

Delivery logs

Every attempt is logged in Webhooks → Logs:

  • HTTP method + URL
  • Request body (full payload)
  • HTTP status returned by your endpoint
  • Response time
  • Response body (first 1 KB, for debugging)
  • Attempt number + next-retry timestamp

Logs retain for 30 days. Filter by webhook, event type, or status to investigate delivery issues.

Local development

Storra can't deliver to localhost. For local dev, tunnel your local server with one of:

  • ngrokngrok http 3000 gives you an HTTPS URL pointing at localhost:3000
  • Cloudflare Tunnelcloudflared tunnel --url http://localhost:3000
  • Tailscale Funnel — for tailnet-internal exposure

Use the tunnel URL as your webhook endpoint while developing.

FAQ

Can I subscribe one webhook to all events?

Yes — tick all event types. Most teams do this for one webhook (sync everything to internal data warehouse) and create separate, narrow webhooks for specific automations (only purchase.completed for the "post to Discord" webhook).

What if my endpoint is slow?

15-second timeout. If your handler does heavy work, ack immediately (return 200) and process asynchronously via a background queue.

Can I see the signing secret again?

No — Storra only stores the hash. Lost it? Rotate via Webhooks → Settings → Rotate signing secret; the old secret is invalidated immediately.

Does the order of events matter?

Storra delivers events in the order they were generated, but your retries might land out of order if some events fail and retry while later ones succeed. If ordering matters for your handler, use the created_at timestamp + idempotency check to handle out-of-order deliveries gracefully.

Was this page helpful?Suggest an edit →

Updated recently