VultPay for platforms
Escrow for every seller on your marketplace
Add your sellers, create escrow deals for them from your server, and release, refund or split each one when you decide. VultPay holds the money, pays your sellers and your commission, and steps in only when a deal is disputed or left too long.
How it works
- You add each of your sellers once, with the bank account they are paid out to.
- Your server creates a deal for a seller, with your commission. The buyer pays into escrow on the VultPay checkout, which names your platform and links your terms.
- You get
escrow.funded. The seller delivers, and you check it your own way. - You send an instruction: release, refund or split. VultPay pays the seller, less the escrow fee and your commission, and pays your commission to your own payout account.
This is Platform mode. There is no automatic release: a funded deal waits for your instruction, within two limits. The hold cap is the longest a deal can wait, and a buyer can always raise a dispute, which you can settle within your dispute window before VultPay decides it.
1. Get your API keys
Create a platform account, then open Developers in the portal. Your platform ID, your keys and your webhook endpoint are there:
vp_live_...keys act on real deals and real money.vp_test_...keys create test-mode deals that run the real flow with simulated money (see Testing).
A key is shown once, when it is created. Keep it on your server. Your escrow rate, hold cap and dispute window are in the portal under Settings, and from GET /sellers/{id}/platform.
2. Making requests
- Base URL:
https://api.vultpay.co. All requests and responses are JSON over HTTPS. - Authenticate with your platform's key in the
X-API-Keyheader. - Every request that changes something needs an
Idempotency-Keyheader, unique per operation. A retry with the same key replays the first response instead of acting twice. - Amounts are in naira. The currency is always NGN.
- Errors use an HTTP status and a body of
{ code, message, details }. Branch oncode.
The samples below share this small client:
const vultpay = async (method, path, body) => {
const res = await fetch(`https://api.vultpay.co${path}`, {
method,
headers: {
"X-API-Key": process.env.VULTPAY_API_KEY, // your platform's key
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!res.ok) throw new Error(`${json.code}: ${json.message}`);
return json;
};3. Add your sellers
Each seller on your marketplace becomes a sub-seller of your platform. They do not sign in to VultPay; you manage them. VultPay pays them out to the account you give.
const seller = await vultpay("POST", "/platform/sub-sellers", {
"business_name": "Ada's Kitchen",
"account_type": "individual",
"contact_email": "ada@example.com",
"payout_account_number": "0123456789",
"payout_bank_code": "058"
});
// Keep seller.id with your own seller record: deals name it as seller_id.
console.log(seller.id);If you switch on seller emails in the portal settings, VultPay emails your sellers when a buyer pays, when they are paid out (with the fee and commission itemised) and when a deal is refunded.
Optional identity checks
You can run a KYC or KYB check on a seller through VultPay with POST /platform/sub-sellers/{id}/verifications. Send the seller a link (method: "link") to complete the check on a VultPay page, or look up a BVN, NIN or CAC number at once with their consent (method: "lookup"). VultPay keeps only the last four digits of the number. A successful live check costs the price shown on your platform account, taken from the seller's first payout.
4. Create a deal
Create deals with the same POST /escrow-transactions merchants use, with the seller's ID as seller_id. A platform creates deals for its sellers, never for itself.
const deal = await vultpay("POST", "/escrow-transactions", {
"seller_id": seller.id,
"amount": 25000,
"category": "goods",
"title": "Order #1042: jollof rice tray",
"release_conditions": "Buyer receives the tray on the delivery date.",
"external_reference": "order-1042",
"commission": {
"rate_bps": 1000
}
});
// 10% commission to you, the escrow fee to VultPay, the rest to the seller on release.
console.log(deal.commission_amount, deal.escrow_fee, deal.seller_receives);
// Send the buyer here to pay.
console.log(deal.payment_link_url);commissionis optional: a fixedamountin naira, or arate_bps(1000 is 10%) of the deal amount. It comes out of the seller's side after the escrow fee and must leave the seller something. The deal shows it ascommission_amount.- The escrow fee is your agreed rate, capped per deal, and is locked onto each deal when you create it.
- Deal amounts must fall within
min_deal_amountandmax_deal_amounton your platform account. Milestone deals work; group (pooled) funding does not. external_reference,metadata,return_urlandcancel_urlwork as for merchants: a repeat create with the same reference returns the first deal.
Send the buyer to payment_link_url. The checkout tells the buyer that VultPay holds their payment and that your platform handles disputes under your terms, and links the terms URL you set in the portal.
5. Release, refund or split
When you are satisfied, send an instruction on the funded deal. It moves the money at once and cannot be undone.
const { instruction, transaction } = await vultpay("POST", `/escrow-transactions/${dealId}/instructions`, {
kind: "release",
reason: "Buyer collected the order",
evidence_ref: "delivery-5531", // your own proof, such as a delivery or ticket id
});
console.log(instruction.applied_at, transaction.state); // "...", "RELEASED"releasepays the seller, less the escrow fee, your commission and any unpaid identity check charge. Your commission is then paid to you.refundreturns everything the buyer paid. No escrow fee or commission is taken.splitpaysseller_amountto the seller side, with the fee and a proportionally smaller commission taken from it, and refunds the rest to the buyer. Single-payer deals only.- A milestone deal takes one instruction per milestone: send
milestone_id. reasonis required andevidence_refis optional. Both are kept with the instruction, along with the key that sent it.
A buyer who confirms receipt on the checkout sends you escrow.buyer_confirmed, but no money moves until you instruct. GET /platform/queue lists every deal waiting for you, most urgent first. The portal shows the same queue.
Hold cap and disputes
Hold cap. A funded deal waits for your instruction for at most your hold cap (30 days unless you choose another length within the range VultPay allows), counted from funding, and for each milestone from its own funding. Every deal shows its hold_cap_deadline. When the cap passes, VultPay opens a dispute on the deal, sends escrow.hold_cap_reached and decides it through its usual review. You can no longer instruct on it.
Buyer disputes. A buyer can raise a dispute on a funded deal. You get dispute.opened and have your dispute window (72 hours unless you choose another length within the range VultPay allows) to settle it with an instruction. If the window closes first, you get escrow.dispute_escalated, VultPay decides the dispute, and the dispute.* events follow it to the end.
Webhooks
Add your endpoint in the portal under Developers. It receives every event for every deal you create for your sellers: the full list merchants get, plus these:
| Event | When |
|---|---|
escrow.buyer_confirmed | The buyer confirmed receipt (the whole deal, or the milestone in data.milestone_id). Advisory: no money moves until you instruct. |
escrow.hold_cap_reached | The hold cap passed without your instruction. VultPay opened a dispute (data.dispute_id) and will decide it. |
escrow.dispute_escalated | Your dispute window closed without your instruction. VultPay now decides the buyer's dispute (data.dispute_id). |
Verify the signature
Every request carries VultPay-Signature: t=<unix seconds>,v1=<hex>, an HMAC-SHA256 of <t>.<raw body> keyed with your signing secret (one v1 per live secret while you rotate it). Check it against the raw body before parsing, and reject timestamps more than 5 minutes from your clock.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
/**
* rawBody: the exact request body bytes as received (a Buffer or string), before any JSON parsing.
* header: the VultPay-Signature header. secret: your endpoint's whsec_... signing secret.
*/
export function verifyVultPaySignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
let timestamp = null;
const signatures = [];
for (const part of String(header || "").split(",")) {
const [key, value] = part.trim().split("=", 2);
if (key === "t") timestamp = Number(value);
else if (key === "v1" && value) signatures.push(value);
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.some((sig) => {
const given = Buffer.from(sig, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}- Answer with any 2xx within 10 seconds. Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours.
- Events can arrive more than once and out of order. De-duplicate on the event
id, and read the deal from the API when the order matters.
Testing
Build against a vp_test_... key. Test deals run the real platform flow, with test_mode: true on every read and event, but only simulated money moves. You play the buyer:
POST /escrow-transactions/{id}/simulate/paymentpays the deal, then your instruction releases, refunds or splits it as it would live.POST /escrow-transactions/{id}/simulate/confirmationrecords the buyer's confirmation and sendsescrow.buyer_confirmed. As live, it releases nothing.POST /escrow-transactions/{id}/simulate/disputeraises a buyer dispute, which you can settle with an instruction inside your window.- Identity checks made with a test key are simulated: they never reach the provider and are never charged.