We use cookies to understand how VultPay is used and improve it. Essential cookies (keeping you signed in) are always on. May we use analytics cookies too?

VultPay for developers

Escrow payments in your own checkout

Create escrow deals from your server, send buyers to a VultPay checkout or drop in the payment button, and get signed webhooks as each deal moves. The buyer's money is held until they confirm delivery, so you never ship against a fake alert.

How it works

  1. Your server creates a deal with the amount, what is being sold, and the release conditions.
  2. VultPay returns a payment link. The buyer pays into escrow by bank transfer or card.
  3. You get escrow.funded. Ship or deliver, then mark the deal fulfilled.
  4. The buyer confirms, or the confirmation window runs out, and the money is released to your payout account. You get escrow.released.

This is Merchant mode: you are the seller, and release follows the same rules as any VultPay deal. You can never release money to yourself. If something goes wrong, the buyer or you raise a dispute and VultPay decides it.

Platform mode, for marketplaces that hold funds on behalf of their own sellers and instruct release by API, is not available yet.

1. Get your API keys

Create a free VultPay account, then open Settings, Developer in the dashboard. There you will find your seller ID and can create keys:

  • vp_live_... keys act on real deals and real money.
  • vp_test_... keys create test-mode deals. They run the real escrow flow and send the same webhooks, but only simulated money ever moves (see Testing).

A key is shown once, when it is created. Keep it on your server, never in a browser, app or repository. Rotating a key keeps the old one working for 24 hours so you can deploy the new one without downtime.

2. Making requests

  • Base URL: https://api.vultpay.co. All requests and responses are JSON over HTTPS.
  • Authenticate with the X-API-Key header.
  • Every request that changes something needs an Idempotency-Key header: a value you generate, unique per operation. Retrying with the same key replays the first response instead of acting twice, so retries after a timeout are safe.
  • Amounts are in naira. The currency is always NGN.
  • Errors use an HTTP status and a body of { code, message, details }. Branch on code, which is stable; message is for people.

3. Create a deal

Send your own order id as external_reference. Creating a deal is idempotent on it: a repeat create with the same reference returns the existing deal (200) instead of opening a second one, and GET /escrow-transactions?external_reference=... finds it again later. metadata is a small key/value bag stored and echoed back as is.

curl https://api.vultpay.co/escrow-transactions \
  -H "X-API-Key: $VULTPAY_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "seller_id": "YOUR_SELLER_ID",
    "amount": 25000,
    "category": "goods",
    "title": "Order #1042: 2 x Ankara tote",
    "release_conditions": "Buyer receives both totes in the colours ordered.",
    "external_reference": "order-1042",
    "metadata": {
      "channel": "web"
    },
    "return_url": "https://shop.example.com/orders/1042"
  }'

The response is the deal, in state CREATED, with its payment_link_url. Categories are goods, service, freelance_milestone and rent_deposit, each with its own amount limits. Milestone deals, group (split) funding, a custom confirmation window and more are in the API reference.

4. Send the buyer to pay

Redirect the buyer to payment_link_url, or send it by email, SMS or chat. Pass buyer_email when creating the deal and VultPay emails the link for you. Buyers do not need a VultPay account.

Set return_url and cancel_url to bring the buyer back to your site. VultPay appends vultpay_transaction_id and vultpay_status to them. Treat those as a hint for what to show the buyer, not as proof of payment: confirm with the webhook or by reading the deal.

Or: the payment button

Paste the button on your product or cart page. When the buyer clicks it, it calls an endpoint on your server, which creates the deal with your API key and answers with the payment link. The button then opens the VultPay checkout. Your key never reaches the browser.

<div
  data-vultpay-button
  data-create-url="/api/create-vultpay-checkout"
  data-param-order-id="1042"
  data-label="Pay securely with escrow"
  data-color="#0b7a4b"
></div>
<script src="https://vultpay.co/vultpay-button.js" async></script>
// Express. The button POSTs { order_id } here with the shopper's cookies.
app.post("/api/create-vultpay-checkout", express.json(), async (req, res) => {
  const order = await loadOrderForShopper(req, req.body.order_id); // your own lookup
  const vp = await fetch("https://api.vultpay.co/escrow-transactions", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.VULTPAY_API_KEY,
      "Idempotency-Key": `order-${order.id}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      seller_id: process.env.VULTPAY_SELLER_ID,
      amount: order.total,
      category: "goods",
      title: `Order #${order.id}`,
      release_conditions: order.deliveryTerms,
      external_reference: `order-${order.id}`,
    }),
  });
  if (!vp.ok) return res.status(502).json({ error: "could not start checkout" });
  const deal = await vp.json();
  res.json({ payment_link_url: deal.payment_link_url });
});

Attributes: data-label, data-color (#rrggbb), data-radius (0 to 24) and any number of data-param-*, which are sent to your endpoint (data-param-order-id arrives as order_id). For a deal you already created, use data-href with its payment link instead of data-create-url. The button fires vultpay:redirect and vultpay:error events, and only ever navigates to a VultPay checkout. The dashboard's Developer tab has a generator that writes this snippet for you and sets your checkout accent colour.

5. Deliver and get paid

After escrow.funded, deliver. Then upload proof (a courier tracking reference, a photo or a pickup confirmation) with POST /escrow-transactions/{id}/evidence and mark the deal delivered with POST /escrow-transactions/{id}/fulfill. Milestone deals are fulfilled one milestone at a time.

The buyer then has the confirmation window (48 hours unless you set auto_release_hours) to confirm or raise a dispute. If they do neither, the money is released automatically. Either way you get escrow.released and the payout goes to the bank account on your VultPay account, less the VultPay fee.

Before anyone pays, you can edit the title and release conditions with PATCH. If the buyer asks for changes first, you get escrow.change_requested. You can cancel a deal until you mark it delivered; anything already paid is refunded to the buyer.

6. Webhooks

Add your endpoint URL under Settings, Developer, Webhooks. It must be public HTTPS. VultPay sends a webhook.ping to check it and shows you the signing secret (whsec_...) once. Each event is a POST with a JSON body like this:

{
  "id": "evt_01J9Z3K8Q6",
  "type": "escrow.funded",
  "created_at": "2026-10-06T09:14:03.000Z",
  "test_mode": false,
  "data": {
    "transaction": {
      "id": "6f1c2b9e-4d7a-4b1e-9c3f-2a8d5e7f0b14",
      "external_reference": "order-1042",
      "state": "FUNDED",
      "amount": 25000,
      "payment_link_url": "https://vultpay.co/checkout/9QK2F7"
    },
    "milestone_id": null,
    "dispute_id": null
  }
}

data.transaction is a snapshot of the deal when the event happened. Buyer contact details are left out; read the deal from the API if you need them.

Events

EventWhen
escrow.fundedThe buyer's payment for the deal (or one milestone) is held in escrow. Safe to ship.
escrow.fulfilledYou marked the deal (or a milestone) delivered. The buyer's confirmation window has started.
escrow.releasedThe money was released to you (the whole deal, or the milestone in data.milestone_id).
escrow.refundedThe deal was refunded to the buyer.
escrow.change_requestedThe buyer asked for changes to the terms before paying.
dispute.openedA dispute was raised (data.dispute_id).
dispute.under_reviewThe dispute moved to a VultPay reviewer.
dispute.decidedThe dispute was decided. Money moves on dispute.settled.
dispute.appealedA party appealed the decision.
dispute.appeal_decidedThe appeal was decided. This decision is final.
dispute.withdrawnThe dispute was withdrawn and the deal carries on.
dispute.settledThe dispute outcome was paid out (refund, release or split).
webhook.pingA test event, sent when you save the endpoint and when you press Send test event.

Verify the signature

Every request carries VultPay-Signature: t=<unix seconds>,v1=<hex>. The v1 value is an HMAC-SHA256 of <t>.<raw body> keyed with your secret. While you rotate the secret, the header carries one v1 per live secret, so accept the request if any of them matches. Use the raw body bytes exactly as received, before parsing JSON, and reject timestamps more than 5 minutes away from your clock to stop replays.

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);
  });
}
// Express: take the raw body for this route, verify, then parse.
app.post("/webhooks/vultpay", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verifyVultPaySignature(req.body, req.get("VultPay-Signature"), process.env.VULTPAY_WEBHOOK_SECRET)) {
    return res.status(400).send("bad signature");
  }
  const event = JSON.parse(req.body.toString("utf8"));
  if (await alreadyProcessed(event.id)) return res.sendStatus(200); // retries reuse the event id

  if (event.type === "escrow.funded") await markOrderPaid(event.data.transaction.external_reference);
  if (event.type === "escrow.released") await markOrderSettled(event.data.transaction.external_reference);
  res.sendStatus(200);
});

Delivery and retries

  • Answer with any 2xx within 10 seconds. Do slow work after you respond.
  • Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours: 7 attempts in all.
  • Events can arrive more than once and out of order. De-duplicate on the event id (also in the VultPay-Event-Id header), and read the deal from the API when the order matters.
  • The dashboard lists recent deliveries with their status and lets you resend any of them.

Disputes

VultPay runs the dispute process; you do not need your own. Through the API you can raise a dispute on a deal, add evidence, withdraw a dispute you raised before it is decided, and appeal a decision once. The dispute.* webhooks tell you where each case stands, and dispute.settled tells you when the money has moved.

Testing

Build against a vp_test_... key. Test deals run through exactly the states, ledger entries and webhooks of a live deal, with test_mode: true on every read and event, but only simulated money moves: nothing reaches a bank, your payout balance or anyone's inbox. You play the buyer, and VultPay for disputes:

  • POST /escrow-transactions/{id}/simulate/payment pays the deal (or the next milestone, or what a pool still needs). The test deal's checkout offers the same as a Simulate payment button, so you can test your payment button end to end.
  • POST /escrow-transactions/{id}/simulate/confirmation confirms delivery and releases the money (pass milestone_id for a milestone deal).
  • POST /escrow-transactions/{id}/simulate/dispute raises a dispute as the buyer, and POST /disputes/{id}/simulate/decision decides it as VultPay would. As live, the money settles when the appeal window closes.
const simulate = async (path, body = {}) => {
  const res = await fetch(`https://api.vultpay.co${path}`, {
    method: "POST",
    headers: {
      "X-API-Key": process.env.VULTPAY_TEST_KEY, // a vp_test_ key
      "Idempotency-Key": crypto.randomUUID(),
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`${json.code}: ${json.message}`);
  return json;
};

await simulate(`/escrow-transactions/${dealId}/simulate/payment`); // you receive escrow.funded
// ...your integration marks the order paid and ships it...
const deal = await simulate(`/escrow-transactions/${dealId}/simulate/confirmation`); // escrow.released
console.log(deal.state); // "RELEASED"

Fulfilment, evidence, cancellation and auto-release work on test deals exactly as on live ones. On a test deal, return_url may also be http://localhost, and card payment is not available. Use Send test event in the Webhooks panel to check your endpoint and signature code before going live.