Webhooks
Coldtill sends an HTTP POST to your endpoints whenever a payment or payout changes. Add endpoints under
Webhooks in the dashboard; each one gets its own signing secret, shown once.
Delivery
- Method and body:
POSTwith a JSON body. - Success: any
2xxresponse within 60 seconds. Redirects are not followed. - Destination: on mainnet, endpoints must be public
httpsURLs. Private, loopback, link-local, carrier-grade NAT and other reserved addresses are refused when connecting, so DNS tricks don't help. - Ordering: not guaranteed. Use the payload's
statusandtimestamp, and treat events as idempotent.
Headers:
| Header | Value |
|---|---|
X-Coldtill-Event |
Event name, for example payment.filled |
X-Coldtill-Delivery |
Unique delivery ID. Retries of the same delivery reuse it |
X-Coldtill-Timestamp |
Unix time in seconds when this attempt was signed |
X-Coldtill-Signature-V2 |
sha256= + hex HMAC-SHA256 of <timestamp>.<raw body> with the endpoint secret |
X-Coldtill-Signature |
sha256= + hex HMAC-SHA256 of the raw body (legacy, no replay protection) |
User-Agent |
Coldtill-Webhook/1 |
Coldtill was called Keyhold before. Every header above is also sent under its old X-Keyhold- name with
the same value, so receivers written before the rename keep working. New code should read X-Coldtill-.
Retries
| Kind | Schedule after the first attempt |
|---|---|
| Progress events | 2 s, 4 s. A later change sends a fresh event anyway |
| Final events | 2 s, 4 s, 30 min, 1 h, 2 h, 4 h, 8 h, 24 h, 48 h, then marked failed |
You can retry any delivery by hand under Webhooks → Deliveries.
Verifying requests
Always verify before trusting the body:
- Read the raw body bytes, before any JSON parsing.
- Check that
X-Coldtill-Timestampis within five minutes of your clock. - Compute
HMAC-SHA256(secret, timestamp + "." + rawBody), hex-encode it, prefixsha256=. - Compare with
X-Coldtill-Signature-V2in constant time. - Optionally, store
X-Coldtill-DeliveryIDs you've processed and ignore repeats.
X-Coldtill-Event and X-Coldtill-Delivery are not signed. Decide what to do from the signed body (its
status, and reference_id for payments), not from the event header. The SDK derives the event from the
body and rejects a request whose header disagrees.
Node.js (SDK)
import express from 'express'
import { verifyWebhookRequest, isPaid } from '@coldtill/sdk'
const app = express()
app.post('/coldtill/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
try {
const ev = await verifyWebhookRequest(req.body, req.headers, process.env.COLDTILL_WEBHOOK_SECRET!)
if (ev.event.startsWith('payment.') && isPaid(ev.data.status)) {
await markOrderPaid(ev.data.invoice_id, ev.data.reference_id)
}
res.sendStatus(200)
} catch {
res.sendStatus(401)
}
})
PHP
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_COLDTILL_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_COLDTILL_SIGNATURE_V2'] ?? '';
$ok = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
&& hash_equals('sha256=' . hash_hmac('sha256', $ts . '.' . $raw, $secret), strtolower($sig));
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($raw, true);
Go
func verify(r *http.Request, body, secret []byte) bool {
ts := r.Header.Get("X-Coldtill-Timestamp")
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 {
return false
}
m := hmac.New(sha256.New, secret)
m.Write([]byte(ts + "."))
m.Write(body)
want := "sha256=" + hex.EncodeToString(m.Sum(nil))
return hmac.Equal([]byte(want), []byte(strings.ToLower(r.Header.Get("X-Coldtill-Signature-V2"))))
}
Python
import hashlib, hmac, time
def verify(headers, raw: bytes, secret: bytes) -> bool:
ts = headers.get("X-Coldtill-Timestamp", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
want = "sha256=" + hmac.new(secret, ts.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, headers.get("X-Coldtill-Signature-V2", "").lower())
Payment events
Event names are payment. plus the lower-cased state: payment.open, payment.partially_filled,
payment.filled, payment.over_filled, payment.cancelled, payment.replaced.
{
"customer_id": "42",
"invoice_id": "INV-1001",
"reference_id": "fd09c7e5-d661-4355-9b04-7b3749b41e2e",
"status": "FILLED",
"amount": "25",
"currency": "USD",
"filled_amount": "25.00000000",
"filled_amount_in_usd": "25.00000000",
"timestamp": 1791647937,
"payment_info": [
{
"source_address": "0x…", "transaction_hash": "0x…", "destination_address": "0x…",
"block_number": 21234567, "network": "BASE", "currency": "USDC", "amount": "25"
}
],
"confirmation_current": 0,
"confirmation_required": 0
}
Progress events (a deposit seen or gaining confirmations) include
confirmation_currentandconfirmation_requiredfor the deposit that triggered them.The final event (
filled,over_filled,cancelled,replaced) is sent once, when the payment closes. A late deposit on a closed payment sends a progress event with the newfilled_amount.Auto-convert: payments paid through a swap include a
conversionarray:"conversion": [{ "provider": "near-intents", "from_currency": "BTC", "from_network": "BTC", "from_amount": "0.000253", "from_amount_in_usd": "15.18", "network": "BASE", "currency": "USDC", "amount": "15", "transaction_hashes": ["0x…"] }]
Payout events
Event names are payout. plus the status: payout.pending-approval, payout.pending, payout.initiated,
payout.sent, payout.failed, payout.rejected, payout.cancelled, payout.needs-review. The body is the
payout object.
payout.needs-review means Coldtill could not tell whether the transaction landed. Don't retry the payout
until an admin has checked the hot wallet's history and resolved it. See Payouts.
Testing
Webhooks → Send test posts a signed test event to an endpoint. On testnet installs you can point
endpoints at http://localhost receivers; mainnet requires public https URLs.