Webhooks

Handling deliveries

Process incoming events reliably.

Rules

  • Verify the signature first.
  • Return 2xx within 30 seconds. Queue heavy work for a background job.
  • Be idempotent on webhook_id — duplicate deliveries must be safe.

Example

JavaScript
app.post('/webhooks/harpoon', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.headers['x-harpoon-signature'];
  const timestamp = parseInt(req.headers['x-harpoon-timestamp'], 10);
  const payload = req.body.toString();

  try {
    verifyWebhookSignature(payload, signature, timestamp, process.env.WEBHOOK_SECRET);
    const event = JSON.parse(payload);

    if (await alreadyProcessed(event.webhook_id)) {
      return res.status(200).json({ received: true });
    }

    switch (event.event) {
      case 'transaction.completed':
        if (event.data.status === 'SUCCESS') {
          await fulfillOrder(event.data.meta.order_id);
        } else if (event.data.status === 'PARTIAL') {
          await notifyPartialPayment(event.data);
        } else if (event.data.status === 'OVERPAID') {
          await fulfillOrderAndLogOverpayment(event.data);
        }
        break;
      case 'transaction.failed':
        await notifyPaymentFailed(event.data);
        break;
      case 'transaction.expired':
        await cancelOrder(event.data.meta.order_id);
        break;
    }

    await markProcessed(event.webhook_id);
    res.status(200).json({ received: true });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

Reading transaction.completed

SUCCESS, PARTIAL, and OVERPAID all arrive on transaction.completed. Branch on data.status:

StatusMeaning
SUCCESSExact match
PARTIALUnderpaid — see data.difference
OVERPAIDOverpaid — see data.difference