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.
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.
| Event | When it fires |
|---|---|
dca.fill | Every successful DCA fill (Jupiter, OmniSwap, or EVM Balmy). Includes swap tx signature + amounts. |
dca.drift | Your USD-per-cycle value drifted past your threshold (5% Free · 3% Pro). |
dca.error | A fill attempt failed (Jupiter quote failed, slippage exceeded, RPC error, etc.). |
dca.complete | A DCA finished all its cycles. |
dca.low_balance | Your input token balance is running low relative to remaining cycles. |
webhook.test | Fired synchronously from the "Send test event" button (bypasses the queue). |
webhook.test.async | Fired asynchronously from POST /api/user/webhook/enqueue-test (goes through the queue → cron → worker path — useful for verifying end-to-end integration). |
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"
}
}
Every request carries three headers. Verify the signature by reconstructing it locally and constant-time comparing.
| Header | Value |
|---|---|
X-OmniSell-Signature | sha256=<hex> — HMAC-SHA256 of timestamp + "." + body_json using your secret. |
X-OmniSell-Timestamp | ISO 8601 UTC. Reject events older than ~5 minutes to prevent replay. |
X-OmniSell-Event | Same as event in the JSON body — convenience for routing without JSON parse. |
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
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);
});
<?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);
If your endpoint returns anything other than 2xx — or times out after 5 seconds — we retry with exponential backoff:
| Attempt | Delay from previous |
|---|---|
| 1 | Immediate (from dispatch) |
| 2 | 60 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 4 hours |
| dead-letter | 24 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.
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.
Two ways to fire a test event, depending on what you want to prove:
POST /api/user/webhook/test
Response: {"ok": true, "status_code": 200, "body": "..."}
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).
Questions? Email scohen@skyyield.io. Bugs? See /trust for the incident inbox.