Skip to content

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

http
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

text
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

text
abs(server_now_unix - WebhookTimestamp) <= 300   # ±5 minutes

Reject anything outside the window.

Receiver Requirements

ItemRequirement
Response statusMust be 2xx; otherwise retry triggers
Response-header timeout5 seconds — server must receive the response status line + headers within 5 s of finishing the TLS handshake
Total request timeout10 seconds — full request including response body must complete within 10 s; slow body streaming counts as a failure
IdempotencySame 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_typeTriggerPayloadCard-types covered
webhook.testConsole "test" / set_webhook async fire-and-test{message, sent_at, acknowledge_to_complete_verification}All
card.openedVirtual card opened{card_id, card_type, status: "opened", opened_at, last_four?}virtual_l/p/v/r/g
card.open_failedOpen failed{card_id, card_type, status: "open_failed", fail_reason, failed_at}virtual_l/p/v/r/g
card.rechargedRecharge succeeded{transaction_id, card_id, status: 2, amount, currency, completed_at}All
card.recharge_failedRecharge failed{transaction_id, card_id, status: 3, amount, currency, fail_reason, failed_at}All
card.closedCard closed{card_id, card_type, status: "closed", closed_at, last_four?}virtual_l/p/v/r/g
card.status_changedOther 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 status is a string: "opened" / "open_failed" / "closed"
  • Recharge status is a number: 2 = success / 3 = failure
  • card_type is lowercase: virtual_v, not VIRTUAL_V

Full Payload Examples

webhook.test

json
{
  "message": "This is a test event from Coinepay OpenAPI",
  "sent_at": "2026-04-29T11:00:00Z",
  "acknowledge_to_complete_verification": true
}

card.opened

json
{
  "card_id": "card_12345",
  "card_type": "virtual_v",
  "status": "opened",
  "opened_at": "2026-04-29T11:00:12Z",
  "last_four": "4242"
}

card.open_failed

json
{
  "card_id": "card_12345",
  "card_type": "virtual_v",
  "status": "open_failed",
  "fail_reason": "kyc rejected",
  "failed_at": "2026-04-29T11:00:12Z"
}

card.recharged

json
{
  "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

json
{
  "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

json
{
  "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.

json
{
  "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
11 minute
25 minutes
315 minutes
41 hour
56 hours
624 hours
7dead_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 modeWhat 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.

ConstraintRule
ProtocolHTTPS in production (HTTP only allowed in dev when AllowHTTP=true)
PortProduction: 443 only. Other ports rejected at delivery time.
IPMust be public IP. Reject 127.0.0.0/8 / 10/8 / 192.168/16 / 172.16-31/12 / 169.254/16 etc.
DomainDNS must resolve to allowed IP range
URL lengthRecommend < 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

python
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)
js
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)
  })
go
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))
}
php
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

PitfallFix
Signing over the parsed JSON objectUse raw body bytes
Confusing webhook_secret with API SECRETThey are two independent secrets
Comparing signatures with ==Use constant-time compare to prevent timing attacks
Not returning 2xx within 5 secondsPush heavy work to a queue; respond 200 immediately. Total request must finish within 10 s (response-header timeout 5 s)
No Webhook-Id dedupAdd an idempotency table; same Webhook-Id returns 200 without re-processing

Released under MIT-equivalent terms.