Webhook integration — verify in 10 min

OmniSell POSTs signed JSON events to your URL whenever one of your DCAs fills, drifts, or transitions state. This page shows the signature scheme, headers, retry policy, and paste-ready verification snippets for Python, Node, and PHP.

Contents

1. Setup (3 steps) 2. Event types 3. Payload structure 4. Signature scheme 5. Verify in Python 6. Verify in Node.js 7. Verify in PHP 8. Retries + dead-letter 9. Rate limits 10. Testing your integration

1. Setup (3 steps)

  1. Open dca.skyyield.io/app → Settings → Add-ons tab.
  2. Enter your HTTPS endpoint URL under 🔔 Webhook notifications.
  3. Click Save URL + Enable webhooks ($5/mo). Your HMAC secret is generated on activation — reveal + copy it into your receiver's environment.

You're now billed $5/mo flat (rolled into your monthly Stripe invoice). Every fill, drift alert, and DCA lifecycle event fires as a signed POST to your URL.

2. Event types

EventWhen it fires
dca.fillEvery successful DCA fill (Jupiter, OmniSwap, or EVM Balmy). Includes swap tx signature + amounts.
dca.driftYour USD-per-cycle value drifted past your threshold (5% Free · 3% Pro).
dca.errorA fill attempt failed (Jupiter quote failed, slippage exceeded, RPC error, etc.).
dca.completeA DCA finished all its cycles.
dca.low_balanceYour input token balance is running low relative to remaining cycles.
webhook.testFired synchronously from the "Send test event" button (bypasses the queue).
webhook.test.asyncFired asynchronously from POST /api/user/webhook/enqueue-test (goes through the queue → cron → worker path — useful for verifying end-to-end integration).

3. Payload structure

All events use a common envelope. Event-specific fields live under data.

{
  "event": "dca.fill",
  "user_id": "8f2e1c4a-...",
  "timestamp": "2026-07-13T22:30:15.123456+00:00",
  "data": {
    "mechanism": "omniswap",
    "chain": "solana",
    "signature": "5AY7Y...",
    "in_mint": "So11111111111111111111111111111111111111112",
    "out_mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
    "amount_in_raw": "1000000000"
  }
}

4. Signature scheme

Every request carries three headers. Verify the signature by reconstructing it locally and constant-time comparing.

HeaderValue
X-OmniSell-Signaturesha256=<hex> — HMAC-SHA256 of timestamp + "." + body_json using your secret.
X-OmniSell-TimestampISO 8601 UTC. Reject events older than ~5 minutes to prevent replay.
X-OmniSell-EventSame as event in the JSON body — convenience for routing without JSON parse.
The signed string is timestamp + "." + body — with a literal dot separator, matching the Stripe convention. Body is the raw request bytes exactly as sent. Do NOT re-serialize — that would break the signature.

5. Verify in Python

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

app = Flask(__name__)
OMNISELL_SECRET = os.environ["OMNISELL_WEBHOOK_SECRET"]

@app.route("/omnisell-webhook", methods=["POST"])
def omnisell_hook():
    body = request.get_data(as_text=True)          # raw string, NOT parsed JSON
    ts   = request.headers.get("X-OmniSell-Timestamp", "")
    sig  = request.headers.get("X-OmniSell-Signature", "")
    expected = "sha256=" + hmac.new(
        OMNISELL_SECRET.encode(),
        (ts + "." + body).encode(),
        hashlib.sha256,
    ).hexdigest()
    if not hmac.compare_digest(sig, expected):
        abort(401, "bad signature")
    event = request.get_json()
    # ... dispatch on event["event"]
    return "", 200

6. Verify in Node.js

import express from "express";
import crypto from "crypto";

const app = express();
const SECRET = process.env.OMNISELL_WEBHOOK_SECRET;

// raw body preserved for signature verification
app.post("/omnisell-webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const body = req.body.toString("utf8");
    const ts   = req.header("X-OmniSell-Timestamp") || "";
    const sig  = req.header("X-OmniSell-Signature") || "";
    const expected = "sha256=" + crypto
      .createHmac("sha256", SECRET)
      .update(ts + "." + body)
      .digest("hex");
    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
      return res.status(401).send("bad signature");
    }
    const event = JSON.parse(body);
    // ... dispatch on event.event
    res.sendStatus(200);
  });

7. Verify in PHP

<?php
$secret   = getenv('OMNISELL_WEBHOOK_SECRET');
$body     = file_get_contents('php://input');
$ts       = $_SERVER['HTTP_X_OMNISELL_TIMESTAMP'] ?? '';
$sig      = $_SERVER['HTTP_X_OMNISELL_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($sig, $expected)) {
    http_response_code(401);
    exit('bad signature');
}
$event = json_decode($body, true);
// ... dispatch on $event['event']
http_response_code(200);

8. Retries + dead-letter

If your endpoint returns anything other than 2xx — or times out after 5 seconds — we retry with exponential backoff:

AttemptDelay from previous
1Immediate (from dispatch)
260 seconds
35 minutes
430 minutes
54 hours
dead-letter24 hours after 5th failure

After the 5th failure the delivery is marked dead and stays visible in your admin ledger for forensics. Fix your endpoint and use the Send test event button in Settings to verify recovery — new events will queue normally.

9. Rate limits

To protect your receiver during a burst of fills, we cap dispatches at 200 events per hour per user. Excess events still queue but with next_attempt_at pushed 30s per overflow so a 300-event burst spreads across ~2.5 hours instead of hammering you.

10. Testing your integration

Two ways to fire a test event, depending on what you want to prove:

  1. Synchronous test — "Send test event" button in Settings. Immediate delivery, response shown inline. Bypasses the queue.
    POST /api/user/webhook/test
    Response: {"ok": true, "status_code": 200, "body": "..."}
  2. Async test (queue path) — proves the full cron → worker → deliver pipeline works.
    POST /api/user/webhook/enqueue-test
    Response: {"ok": true, "delivery": {"id": 42, "status": "pending", ...}}
    Poll GET /api/user/webhook/deliveries and watch id=42 flip from pending → delivered within ~60s (next cron tick).
Never log the plaintext secret. If you accidentally commit it to source control, hit Save + Roll secret in Settings to rotate — old signatures will stop verifying immediately.

Questions? Email scohen@skyyield.io. Bugs? See /trust for the incident inbox.