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:
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#
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#
| Event | When |
|---|---|
payment_intent.successful | The money arrived (including late transfers). |
payment_intent.failed | A payment attempt failed; the customer may try again. |
payment_intent.abandoned | Processing for 30 minutes with no money. |
payment_intent.canceled | You canceled the payment. |
refund.succeeded / refund.failed | A refund was confirmed or failed. |
payout.paid / payout.failed / payout.canceled | A payout reached the bank, failed (money returned to your balance), or was canceled. |
dispute.created | A customer disputed a payment. Respond before respond_by. |
dispute.won / dispute.lost | The dispute was decided. |
invoice.paid | An invoice was paid. |
crypto_payout.paid / crypto_payout.failed | A 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.
- Take
tandv1from the header, and the event id fromBiftpay-Event-Id. - Compute the HMAC over the raw request body (before any JSON parsing).
- Compare it with
v1in constant time. - Reject it if
tis more than 5 minutes old, so a captured request can't be replayed later.
Node.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
$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#
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 "", 200Answer 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.