Webhooks

Webhooks allow your application to receive real-time notifications when events occur in your crypto payment infrastructure. Get instant alerts for deposits, withdrawals, balance changes, and more.

Registering webhooks

Configure your webhook URL using the organization endpoint to receive event notifications:

Set webhook URL

curl -X PATCH https://api.coinspayd.io/organisation/webhook \
  -H "x-api-key: {your-api-key}" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://your-app.com/webhooks/coinspayd"
  }'

Once configured, the platform will send HTTP POST requests to your webhook URL whenever relevant events occur.

Consuming webhooks

Your webhook endpoint should:

  1. Accept HTTP POST requests
  2. Parse the JSON payload
  3. Verify the signature (see Security)
  4. Process the event based on the type field
  5. Return a 2xx status code to acknowledge receipt

Example webhook handler

app.post('/webhooks/coinspayd', async (req, res) => {
  const { type, payload } = req.body

  // Verify signature (see Security section)
  if (!verifySignature(req)) {
    return res.status(401).send('Invalid signature')
  }

  switch (type) {
    case 'deposit.detected':
      await handleNewDeposit(payload)
      break
    case 'withdrawal.completed':
      await handleWithdrawalComplete(payload)
      break
    case 'withdrawal.failed':
      await handleWithdrawalFailed(payload)
      break
    default:
      console.log(`Unknown event type: ${type}`)
  }

  res.status(200).send('OK')
})

Event types

Deposit events

  • Name
    deposit.detected
    Description

    A new cryptocurrency deposit was detected on the blockchain. This is sent as soon as the transaction is seen, before confirmations.

  • Name
    deposit.confirmed
    Description

    A deposit has received sufficient blockchain confirmations and has been credited to the user's account.

Withdrawal events

  • Name
    withdrawal.created
    Description

    A new withdrawal request was created and is pending approval or processing.

  • Name
    withdrawal.approved
    Description

    A withdrawal has received the required number of approvals and will begin processing.

  • Name
    withdrawal.processing
    Description

    A withdrawal transaction has been broadcast to the blockchain.

  • Name
    withdrawal.completed
    Description

    A withdrawal transaction has been confirmed on the blockchain.

  • Name
    withdrawal.failed
    Description

    A withdrawal failed due to insufficient gas, network issues, or other errors.

Account events

  • Name
    account.created
    Description

    A new deposit account (address) was created for a user.

  • Name
    balance.updated
    Description

    An account balance changed due to a deposit, withdrawal, or sweep.

deposit.detected

{
  "type": "deposit.detected",
  "timestamp": "2025-01-30T10:30:00.000Z",
  "payload": {
    "chainId": "eip155:1",
    "txnHash": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
    "tokenId": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
    "amount": "100000000",
    "externalUserId": "customer_789",
    "accountChainId": "eip155:1",
    "senderAddress": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd",
    "blockNumber": 18500000,
    "confirmations": 0,
    "Token": {
      "id": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
      "name": "USD Coin",
      "symbol": "USDC",
      "decimals": 6,
      "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
    }
  }
}

withdrawal.completed

{
  "type": "withdrawal.completed",
  "timestamp": "2025-01-30T11:00:00.000Z",
  "payload": {
    "id": "withdrawal_abc123",
    "chainId": "eip155:137",
    "tokenId": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
    "amount": "50000000",
    "toAddress": "0x9876543210987654321098765432109876543210",
    "txnHash": "0xfedcbafedcbafedcbafedcbafedcbafedcbafedcbafedcbafedcbafedcbafed",
    "status": "Completed",
    "externalUserId": "customer_456",
    "approvedBy": ["user_001", "user_002"],
    "createdAt": "2025-01-30T10:45:00.000Z",
    "completedAt": "2025-01-30T11:00:00.000Z",
    "Token": {
      "id": "eip155:137/erc20:0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
      "name": "USD Coin",
      "symbol": "USDC",
      "decimals": 6,
      "address": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174"
    }
  }
}

account.created

{
  "type": "account.created",
  "timestamp": "2025-01-30T09:00:00.000Z",
  "payload": {
    "orgId": "org_456",
    "chainId": "xrpl:mainnet",
    "externalUserId": "customer_999",
    "orgDepositAccountId": "acc_xyz789",
    "properties": {
      "address": "rN7n7otQDd6FczFgLdlqtyMVrn3HMtthca",
      "destinationTag": "54321"
    },
    "createdAt": "2025-01-30T09:00:00.000Z"
  }
}

Security

To verify that a webhook was genuinely sent by the Crypto Deposits Platform and not by a malicious actor, you should verify the request signature. Each webhook request includes an x-webhook-signature header containing an HMAC SHA-256 hash of the request payload.

Getting your webhook secret

Your webhook secret is provided when you configure your webhook URL. Keep this secret secure and never commit it to version control.

Verifying signatures

Verifying webhook signatures

const crypto = require('crypto')

function verifyWebhookSignature(req, secret) {
  const signature = req.headers['x-webhook-signature']
  const payload = JSON.stringify(req.body)

  const hash = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex')

  return hash === signature
}

// Usage in Express
app.post('/webhooks/coinspayd', (req, res) => {
  if (!verifyWebhookSignature(req, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' })
  }

  // Process webhook
  const { type, payload } = req.body
  // ...

  res.status(200).send('OK')
})

Important security practices:

  • Always verify signatures before processing webhook data
  • Use constant-time comparison functions to prevent timing attacks
  • Store webhook secrets in environment variables or secrets managers
  • Use HTTPS endpoints to prevent man-in-the-middle attacks
  • Implement rate limiting to prevent abuse

Retry logic

If your webhook endpoint returns a non-2xx status code or times out, the platform will retry delivery with exponential backoff:

AttemptDelay
1st retry5 seconds
2nd retry25 seconds
3rd retry2 minutes
4th retry10 minutes
5th retry1 hour

After 5 failed attempts, the webhook is marked as failed and will not be retried. You can view failed webhooks in your dashboard.


Testing webhooks

Local development

Use a tool like ngrok to expose your local server for webhook testing:

# Start ngrok
ngrok http 3000

# Update your webhook URL to the ngrok URL
curl -X PATCH https://api.coinspayd.io/organisation/webhook \
  -H "x-api-key: {your-api-key}" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookUrl": "https://abc123.ngrok.io/webhooks/coinspayd"
  }'

Manual testing

Trigger test deposits by sending crypto to a test deposit address on a testnet:

# Get a deposit address on Sepolia testnet
curl -G https://api.coinspayd.io/payments/deposit-address \
  -H "x-api-key: {your-api-key}" \
  -d chainId=eip155:11155111 \
  -d tokenId=eip155:11155111/slip44:60 \
  -d userId=test_user_001

Then send testnet ETH to the returned address and monitor your webhook endpoint for the deposit.detected event.


Best practices

Idempotency

Webhooks may be delivered more than once. Design your webhook handler to be idempotent by:

  • Tracking processed webhook IDs in your database
  • Using transaction hashes as idempotency keys
  • Checking if an event was already processed before taking action
async function handleDeposit(payload) {
  const { txnHash, externalUserId, amount } = payload

  // Check if already processed
  const existing = await db.deposits.findOne({ txnHash })
  if (existing) {
    console.log(`Deposit ${txnHash} already processed`)
    return
  }

  // Process deposit
  await db.deposits.create({
    txnHash,
    userId: externalUserId,
    amount,
    processedAt: new Date()
  })

  await creditUserAccount(externalUserId, amount)
}

Asynchronous processing

Process webhooks asynchronously to avoid timeouts:

app.post('/webhooks/coinspayd', async (req, res) => {
  // Verify signature
  if (!verifyWebhookSignature(req, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature')
  }

  // Acknowledge immediately
  res.status(200).send('OK')

  // Process asynchronously
  const { type, payload } = req.body
  queue.add('process-webhook', { type, payload })
})

Monitoring

Monitor webhook delivery:

  • Track webhook delivery success rates
  • Alert on repeated failures
  • Log webhook processing times
  • Monitor for unusual patterns (e.g., duplicate events)

Error handling

Handle errors gracefully:

app.post('/webhooks/coinspayd', async (req, res) => {
  try {
    if (!verifyWebhookSignature(req, WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature')
    }

    const { type, payload } = req.body

    switch (type) {
      case 'deposit.detected':
        await handleDeposit(payload)
        break
      case 'withdrawal.completed':
        await handleWithdrawalComplete(payload)
        break
      default:
        console.log(`Unknown event type: ${type}`)
    }

    res.status(200).send('OK')
  } catch (error) {
    console.error('Webhook processing error:', error)

    // Return 5xx for retryable errors, 4xx for permanent failures
    if (error.retryable) {
      res.status(503).send('Service temporarily unavailable')
    } else {
      res.status(400).send('Bad request')
    }
  }
})

Was this page helpful?