Biftpaydocs

Webhooks

Biftpay tells your server when something happens (a payment succeeds, a payout is paid, a dispute opens) by sending an HTTPS POST to your webhook endpoint. Use webhooks rather than waiting on the customer's browser: they arrive even if the customer closes the page or pays by transfer later.

Add an endpoint#

In the dashboard under Developers โ†’ Webhooks, or with the API:

bash
curl https://sandbox.api.biftpay.com/v1/webhook_endpoints \
  -H "Authorization: Bearer bp_test_..." \
  -H "Idempotency-Key: 3c1f..." \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/biftpay/webhooks", "events": ["payment_intent.successful"] }'

Leave out events to receive every event. The response includes the endpoint's signing secret (whsec_...). It's shown once; store it with your other secrets. Test and live mode have their own endpoints, up to 10 each. The URL must be HTTPS on port 443 or 8443, with a public hostname (not an IP address).

Disable, enable or delete an endpoint#

  • Disable (`POST /v1/webhook_endpoints/{id}/disable`) stops deliveries for now, for example while your server is down for maintenance. Deliveries still waiting are dropped.
  • Enable (`POST /v1/webhook_endpoints/{id}/enable`) turns it back on. New events go to it from then; send the ones it missed again with `POST /v1/events/{id}/redeliver`.
  • Delete (`POST /v1/webhook_endpoints/{id}/delete`) removes it for good: nothing more is sent and it leaves the list. Its past deliveries stay in the event history. To use the same URL again, add it as a new endpoint (with a new signing secret).

The dashboard has the same buttons under Developers โ†’ Webhooks.

What you receive#

http
POST /biftpay/webhooks HTTP/1.1
Content-Type: application/json
Biftpay-Event-Id: 0d6c2a52-3b5e-4d1f-9a2b-6c7d8e9f0a1b
Biftpay-Timestamp: 1767225600
Biftpay-Signature: t=1767225600,v1=5f1e0c...

{
  "id": "0d6c2a52-3b5e-4d1f-9a2b-6c7d8e9f0a1b",
  "object": "event",
  "type": "payment_intent.successful",
  "livemode": false,
  "created_at": "2026-01-01T00:00:00.000Z",
  "data": { "object": { "id": "5b0c3f6e-...", "object": "payment_intent", "status": "successful" } }
}

data.object is the object as it was when the event happened.

Event types#

EventWhen
payment_intent.successfulThe money arrived (including late transfers).
payment_intent.failedA payment attempt failed; the customer may try again.
payment_intent.abandonedProcessing for 30 minutes with no money.
payment_intent.canceledYou canceled the payment.
refund.succeeded / refund.failedA refund was confirmed or failed.
payout.paid / payout.failed / payout.canceledA payout reached the bank, failed (money returned to your balance), or was canceled.
dispute.createdA customer disputed a payment. Respond before respond_by.
dispute.won / dispute.lostThe dispute was decided.
invoice.paidAn invoice was paid.
crypto_payout.paid / crypto_payout.failedA USDT payout was sent or failed.

Check the signature#

Every delivery is signed with your endpoint's secret. Check it before trusting the body, otherwise anyone who finds your URL could send you fake events.

Biftpay-Signature is t=<unix seconds>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<event id>.<raw body> with your secret as the key.

  1. Take t and v1 from the header, and the event id from Biftpay-Event-Id.
  2. Compute the HMAC over the raw request body (before any JSON parsing).
  3. Compare it with v1 in constant time.
  4. Reject it if t is more than 5 minutes old, so a captured request can't be replayed later.

Node.js#

js
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/biftpay/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Biftpay-Signature") ?? "";
  const eventId = req.get("Biftpay-Event-Id") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const body = req.body.toString("utf8");

  const expected = crypto
    .createHmac("sha256", process.env.BIFTPAY_WEBHOOK_SECRET)
    .update(`${parts.t}.${eventId}.${body}`)
    .digest("hex");

  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const valid =
    parts.v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!fresh || !valid) return res.status(400).send("bad signature");

  const event = JSON.parse(body);
  // Handle the event (see "Handle each event once" below), then answer quickly.
  res.sendStatus(200);
});

PHP#

php
<?php
$body = file_get_contents('php://input');
$eventId = $_SERVER['HTTP_BIFTPAY_EVENT_ID'] ?? '';
parse_str(str_replace(',', '&', $_SERVER['HTTP_BIFTPAY_SIGNATURE'] ?? ''), $parts);

$expected = hash_hmac('sha256', $parts['t'] . '.' . $eventId . '.' . $body, getenv('BIFTPAY_WEBHOOK_SECRET'));
$fresh = abs(time() - (int) $parts['t']) < 300;

if (!$fresh || !hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit('bad signature');
}

$event = json_decode($body, true);
// Handle the event, then answer quickly.
http_response_code(200);

Python#

python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/biftpay/webhooks")
def biftpay_webhook():
    body = request.get_data(as_text=True)
    event_id = request.headers.get("Biftpay-Event-Id", "")
    parts = dict(p.split("=", 1) for p in request.headers.get("Biftpay-Signature", "").split(",") if "=" in p)

    expected = hmac.new(
        os.environ["BIFTPAY_WEBHOOK_SECRET"].encode(),
        f"{parts.get('t')}.{event_id}.{body}".encode(),
        hashlib.sha256,
    ).hexdigest()
    fresh = abs(time.time() - int(parts.get("t", 0))) < 300
    if not fresh or not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)

    event = request.get_json()
    # Handle the event, then answer quickly.
    return "", 200

Answer quickly, retry safely#

Reply with any 2xx within 10 seconds. Anything else (an error, a timeout, a redirect) counts as a failure, and Biftpay tries again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours. After that the delivery is marked failed; you can send it again from the dashboard or with POST /v1/events/{id}/redeliver.

If your work takes longer than a few seconds, store the event and reply 200 straight away, then process it in the background.

Handle each event once#

The same event can arrive more than once (after a retry, or if you redeliver it). Each event has a unique id: record the ids you've handled and skip repeats. Events can also arrive out of order, so when it matters, fetch the latest state (for example GET /v1/payment_intents/{id}) instead of relying on the order of events.

See what was sent#

GET /v1/events lists recent events with each delivery's status, attempts and the last response your server gave. The dashboard shows the same under Developers โ†’ Events.