Webhook Spec
When async events complete (card opened, recharge succeeded, card closed, etc.), Coinepay actively POSTs to your configured URL. Clients must verify the signature + return 2xx within the timeout window.
Inbound HTTP Request
POST https://customer-server.example/your-webhook-path HTTP/1.1
Content-Type: application/json
Webhook-Id: 550e8400-e29b-41d4-a716-446655440000
Webhook-Timestamp: 1714377612
Webhook-Signature: v1,9b8e7f6d5c4b3a2918273645d4e3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b
Webhook-Type: card.opened
User-Agent: Coinepay-Webhook/1.0
<JSON body>Signature Verification
signInput = WebhookTimestamp + "." + raw_body_bytes
expected = "v1," + lowercase_hex( HMAC_SHA256(webhook_secret_bytes, signInput) )
verify_ok = constant_time_compare(Webhook-Signature, expected)Use the raw body bytes
Do NOT JSON.parse then re-stringify — whitespace / key-order may change and the signature will fail. Use your framework's raw body capability (bodyParser.raw in Express, io.ReadAll(r.Body) in Go, etc.).
webhook_secret is NOT the API secret
- API
SECRET— used for client → server signing. webhook_secret— used for server → client webhook signing.
The webhook_secret returned from set_webhook is independent.
Timestamp Replay Protection
abs(server_now_unix - WebhookTimestamp) <= 300 # ±5 minutesReject anything outside the window.
Receiver Requirements
| Item | Requirement |
|---|---|
| Response status | Must be 2xx; otherwise retry triggers |
| Response-header timeout | 5 seconds — server must receive the response status line + headers within 5 s of finishing the TLS handshake |
| Total request timeout | 10 seconds — full request including response body must complete within 10 s; slow body streaming counts as a failure |
| Idempotency | Same Webhook-Id may arrive multiple times (delivery retries) — dedupe by Webhook-Id |
Pratical guideline
Push heavy work to a background queue and respond 200 immediately. Aim for < 200 ms end-to-end on the webhook endpoint to leave headroom for network jitter.
Idempotency required
Even after you respond 200, packet loss may make Coinepay retry. Always dedupe by Webhook-Id to avoid double-processing.
Event Types
| event_type | Trigger | Payload | Card-types covered |
|---|---|---|---|
webhook.test | Console "test" / set_webhook async fire-and-test | {message, sent_at, acknowledge_to_complete_verification} | All |
card.opened | Virtual card opened | {card_id, card_type, status: "opened", opened_at, last_four?} | virtual_l/p/v/r/g |
card.open_failed | Open failed | {card_id, card_type, status: "open_failed", fail_reason, failed_at} | virtual_l/p/v/r/g |
card.recharged | Recharge succeeded | {transaction_id, card_id, status: 2, amount, currency, completed_at} | All |
card.recharge_failed | Recharge failed | {transaction_id, card_id, status: 3, amount, currency, fail_reason, failed_at} | All |
card.closed | Card closed | {card_id, card_type, status: "closed", closed_at, last_four?} | virtual_l/p/v/r/g |
card.status_changed | Other status transitions (manual freeze / unfreeze / risk-driven holds) | {card_id, card_type, from_status, to_status, changed_at, last_four?} | virtual_l/p/v/r/g |
v1.2 status convention
- Open / close
statusis a string:"opened"/"open_failed"/"closed" - Recharge
statusis a number:2= success /3= failure card_typeis lowercase:virtual_v, notVIRTUAL_V
Full Payload Examples
webhook.test
{
"message": "This is a test event from Coinepay OpenAPI",
"sent_at": "2026-04-29T11:00:00Z",
"acknowledge_to_complete_verification": true
}card.opened
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "opened",
"opened_at": "2026-04-29T11:00:12Z",
"last_four": "4242"
}card.open_failed
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "open_failed",
"fail_reason": "kyc rejected",
"failed_at": "2026-04-29T11:00:12Z"
}card.recharged
{
"transaction_id": "txn_RO20260429120000xyz",
"card_id": "card_12345",
"status": 2,
"amount": "100.00",
"currency": "USD",
"completed_at": "2026-04-29T12:00:00Z"
}card.recharge_failed
{
"transaction_id": "txn_RO20260429120000xyz",
"card_id": "card_12345",
"status": 3,
"amount": "100.00",
"currency": "USD",
"fail_reason": "provider declined",
"failed_at": "2026-04-29T12:00:01Z"
}card.closed
{
"card_id": "card_12345",
"card_type": "virtual_v",
"status": "closed",
"closed_at": "2026-04-29T13:00:00Z",
"last_four": "4242"
}card.status_changed
Fired when a card transitions between non-terminal states that aren't covered by the dedicated card.opened / card.closed events — typically manual freeze / unfreeze by the cardholder or risk-driven holds.
{
"card_id": "card_12345",
"card_type": "virtual_v",
"from_status": "active",
"to_status": "frozen",
"changed_at": "2026-04-29T14:30:00Z",
"last_four": "4242"
}from_status / to_status are lowercase strings (active / frozen / pending / closing). For terminal closed / failed transitions, prefer the more specific card.closed / card.open_failed events.
Retry Backoff
| Failure # | Next attempt in |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
| 6 | 24 hours |
| 7 | dead_letter — no more retries |
Total delivery attempts
Each event has 7 delivery attempts (1 initial + 6 retries). After the 7th failure the event moves to dead_letter and stops retrying.
Inspect dead-letter status via the Webhook events history endpoint.
fail_reason — Server-Side Desensitization
fail_reason on card.open_failed and card.recharge_failed payloads is a sanitized, short, human-readable description. The server strips:
- Provider URLs / IPv4 addresses / email addresses / domain names
- Go-style transport templates (e.g.
Post,dial tcp ...) - HTML and special characters
- Truncates to 120 characters
| Failure mode | What you'll see in fail_reason |
|---|---|
| Synchronous failure (API rejected the call immediately) | "" (empty string) — render a generic message |
| Asynchronous failure (provider returned an error after queueing) | Cleaned short description like "Insufficient funds", "Card blocked" — never contains provider domain / IP / internal trace IDs / stack traces |
Need the raw provider error?
Call /api/v1/openapi/webhook_events/list and look at last_error for ops-level diagnostic info. The full provider response is retained server-side; contact support if you need it for an investigation.
URL Constraints (set_webhook)
Verification lifecycle
Each call to set_webhook rotates webhook_secret and clears the verified flag on the credential. The flag is re-set the moment the next outbound delivery (typically the auto-fired webhook.test) returns 2xx, signaling that the new endpoint is reachable. You can read the flag via get_webhook to detect "configured but never reached" misconfigurations.
| Constraint | Rule |
|---|---|
| Protocol | HTTPS in production (HTTP only allowed in dev when AllowHTTP=true) |
| Port | Production: 443 only. Other ports rejected at delivery time. |
| IP | Must be public IP. Reject 127.0.0.0/8 / 10/8 / 192.168/16 / 172.16-31/12 / 169.254/16 etc. |
| Domain | DNS must resolve to allowed IP range |
| URL length | Recommend < 1024 chars |
SSRF protection
Rejection of internal / cloud-metadata (169.254.169.254) / loopback is mandatory. If your receiver is internal, expose it via a reverse proxy with public HTTPS.
Verification Code Snippets
import hmac, hashlib
def verify_webhook(sig_header, ts_header, raw_body, webhook_secret):
if not sig_header.startswith("v1,"):
return False
sig = sig_header[3:]
expected = hmac.new(
webhook_secret.encode(),
f"{ts_header}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(sig, expected)import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/webhook',
express.raw({ type: 'application/json' }), // raw body
(req, res) => {
const sigHeader = req.header('Webhook-Signature') || ''
const tsHeader = req.header('Webhook-Timestamp') || ''
if (!sigHeader.startsWith('v1,')) return res.sendStatus(401)
const sig = sigHeader.slice(3)
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${tsHeader}.`).update(req.body)
.digest('hex')
const ok =
Buffer.byteLength(sig) === Buffer.byteLength(expected) &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
if (!ok) return res.sendStatus(401)
// process...
res.sendStatus(200)
})func verifyWebhook(sigHeader, tsHeader string, rawBody []byte, webhookSecret string) bool {
const prefix = "v1,"
if !strings.HasPrefix(sigHeader, prefix) {
return false
}
sig := sigHeader[len(prefix):]
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(tsHeader))
mac.Write([]byte("."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}function verifyWebhook(string $sigHeader, string $tsHeader, string $rawBody, string $webhookSecret): bool {
if (strpos($sigHeader, 'v1,') !== 0) return false;
$sig = substr($sigHeader, 3);
$expected = hash_hmac('sha256', $tsHeader . '.' . $rawBody, $webhookSecret);
return hash_equals($sig, $expected);
}Common Pitfalls
| Pitfall | Fix |
|---|---|
| Signing over the parsed JSON object | Use raw body bytes |
Confusing webhook_secret with API SECRET | They are two independent secrets |
Comparing signatures with == | Use constant-time compare to prevent timing attacks |
| Not returning 2xx within 5 seconds | Push heavy work to a queue; respond 200 immediately. Total request must finish within 10 s (response-header timeout 5 s) |
No Webhook-Id dedup | Add an idempotency table; same Webhook-Id returns 200 without re-processing |