XcrowPay Integration API
Version: v1 (draft)
Base URL: https://xcrowpay.com/api
Audience: Backend developers integrating an ecommerce store, marketplace, P2P exchange, or other platform with XcrowPay's escrow payment rails.
1. Overview
The Integration API lets a platform use XcrowPay as its payment gateway while getting escrow protection for free: instead of settling funds to the platform's merchant immediately, XcrowPay holds the buyer's payment until the buyer confirms delivery (or the transaction is otherwise resolved), then releases it to the merchant's connected bank account.
A merchant on this API is an XcrowPay user account plus a set of API credentials. Registering for API access is the same as creating a regular XcrowPay account — this means a merchant's KYC tier, wallet, and payouts all work exactly like a normal seller's, just driven by API calls instead of the dashboard.
The buyer/customer on this API never needs an XcrowPay account. They are identified by email (and optionally a WhatsApp number), and everything they do — pay, verify delivery, confirm receipt, or dispute — happens either through a hosted link XcrowPay gives you (payment_url), or via API calls your own backend makes on their behalf. Because the buyer has no account, the buyer side of a transaction has no independent transaction limit — only the merchant's own KYC tier and daily limit apply.
2. Authentication
All /v1/* transaction endpoints (except registration) require your secret key as a bearer token:
Authorization: Bearer {secret_key}
Content-Type: application/json
Requests without a valid key return 401. There is no separate JWT step — the secret key is the credential for every server-to-server call.
Never expose your secret key in client-side code. The public_key returned at registration is not currently used by any endpoint in v1 but is reserved for future client-side use cases (e.g. a hosted widget) — treat it as public, and the secret key as the only real credential.
Test vs. live keys
Every merchant gets two independent key pairs, exactly like Paystack:
| Test key | Live key | |
|---|---|---|
| Available | Immediately at registration | Only after business verification is approved (§2.2) |
| Money movement | None — fully simulated, never touches Korapay | Real, via Korapay |
| KYC/tier limits | Not enforced | Enforced (§3) |
| WhatsApp messages | Never sent | Sent normally |
| Webhooks | Fire normally (with "test": true on the payload) | Fire normally |
Which mode a request runs in is determined entirely by which secret key you send — there's no separate "switch to test mode" toggle on your account.
Getting credentials
POST /v1/merchants/register
Public endpoint. Creates your XcrowPay account and merchant record in one call, and issues your test key pair immediately — no verification required to start integrating.
Request body
{
"businessName": "Lagos Sneaker Store",
"fullName": "Ada Okafor",
"email": "ada@lagossneakers.com",
"password": "••••••••",
"phone": "08012345678"
}
Response 201
{
"merchant_id": "m_9f8a...",
"key_mode": "test",
"public_key": "xcrow_pk_test_...",
"secret_key": "xcrow_sk_test_...",
"webhook_secret": "whsec_...",
"dashboard_url": "https://xcrowpay.com/dashboard"
}
Already have an XcrowPay account? Use POST /v1/merchants/register-existing instead (session-authenticated, { "businessName": "..." }) from your dashboard rather than creating a second account.
secret_keyandwebhook_secretare shown once, here, and never again. Store them immediately in your own secrets manager. If lost, rotate viaPOST /v1/merchants/rotate-secret(requires a logged-in session, not an API key — do this from your XcrowPay dashboard login, not server-to-server).
Managing your merchant account (session-authenticated, not API-key)
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /v1/merchants/me | View your public keys, masked secret keys, webhook URL, verification status |
| POST | /v1/merchants/rotate-secret | { "mode": "test" | "live" } — invalidate that mode's secret key and issue a new one |
| POST | /v1/merchants/live-key/reveal | Reveal your live secret key once, right after approval (see §2.2) |
| PATCH | /v1/merchants/webhook | Set/update your webhook URL ({ "webhookUrl": "https://..." }, must be https://) |
2.2 Business Verification (required for live keys)
Modeled on Paystack's business verification wizard: Business Type → Business Profile → Documents → Representative → Bank Details. Submit once, an XcrowPay admin reviews it, and approval unlocks live keys automatically — nothing else to do on your side. This is also available as a guided form in the dashboard (Developer / API page), not only via API.
You do not need to call /v1/merchants/register (or click "Get Test Keys Now" in the dashboard) before submitting verification. If you go straight to POST /v1/merchants/verification/submit with no merchant record yet, one is created for you automatically (with a test key pair) using the businessName you include in the submit body — the response then includes a one-time new_merchant: { public_key, secret_key } so you still get your test keys, just as a side effect of submitting rather than a separate step.
POST /v1/merchants/verification/upload -- upload each document, one call per file
POST /v1/merchants/verification/submit -- submit the full application once documents are uploaded
GET /v1/merchants/verification/status -- check where your submission stands
Business Type determines which documents are required:
| Business Type | Required documents |
|---|---|
individual | Proof of address (utilityBill) |
registered_business (sole proprietorship) | cacCertificate, utilityBill |
registered_business + isLimitedLiability: true (LTD/LLC) | cacCertificate, memart, utilityBill |
ngo | cacCertificate (CAC-IT), ngoConstitution, utilityBill |
A representative's valid ID (representativeId) is always required, regardless of business type.
Upload (session-authenticated, one call per document):
{ "fileName": "cac.pdf", "contentType": "application/pdf", "dataBase64": "...", "docType": "cacCertificate" }
docType is one of cacCertificate, memart, ngoConstitution, utilityBill, representativeId. Each call returns { "url": "...", "docType": "..." } — collect these URLs for the submit call.
Submit:
{
"businessName": "Lagos Sneaker Store",
"businessType": "registered_business",
"isLimitedLiability": true,
"rcNumber": "RC1234567",
"tin": "12345678-0001",
"businessProfile": {
"description": "Sneaker reselling and imports",
"category": "Fashion & Apparel",
"officialEmail": "hello@lagossneakers.com",
"phone": "08012345678",
"address": "12 Allen Avenue",
"city": "Ikeja",
"state": "Lagos"
},
"documents": {
"cacCertificate": "https://.../cac.pdf",
"memart": "https://.../memart.pdf",
"utilityBill": "https://.../bill.pdf"
},
"representative": {
"fullName": "Ada Okafor",
"dateOfBirth": "1990-04-12",
"bvn": "12345678901",
"email": "ada@lagossneakers.com",
"phone": "08012345678",
"idType": "nin",
"idNumber": "12345678901",
"idDocument": "https://.../id.pdf"
}
}
businessName is required only if you don't already have a merchant record (omit it once you do — the merchant's existing name is used). businessProfile and representative are required in full for every business type. rcNumber is required unless businessType is individual. tin and isLimitedLiability are optional (the latter only meaningful for registered_business). There can only be one pending submission at a time.
Bank Details are not collected in this call — they're read automatically from the payout bank account already configured in your XcrowPay Settings. Submission is rejected with 400 if no payout bank account is on file yet.
On approval:
- Your live key pair is generated (
POST /v1/merchants/live-key/revealbecomes available to fetch it once). - Your linked XcrowPay account is raised straight to Tier 2 — you do not need to complete Tier 1 first. If you need Tier 3, that still goes through the normal personal KYC upgrade flow in Settings.
On rejection: GET /v1/merchants/verification/status returns the admin's notes; fix the issue and submit again.
3. KYC & Transaction Limits (live keys only)
Test-key transactions are exempt from everything in this section — there's no real money at risk, so there's nothing to limit.
For live keys, once your business is verified you start at Tier 2 automatically:
| Tier | Daily Limit |
|---|---|
| 0 (no KYC) | ₦100,000 |
| 1 | ₦500,000 |
| 2 (verified merchants start here) | ₦5,000,000 |
| 3 | ₦25,000,000 |
A transaction that would push your account's total volume for the day over your tier's limit is rejected with 403 and reason: "DAILY_LIMIT_EXCEEDED". A transaction requiring a higher tier than you currently hold is rejected with 403 and reason: "KYC_TIER_UPGRADE_REQUIRED".
The buyer is never subject to any of this — only your merchant account's tier and daily volume matter.
4. Endpoints
| Method | Endpoint | Description |
|---|---|---|
| POST | /v1/transactions | Create an escrow transaction |
| GET | /v1/transactions/{id} | Retrieve a transaction |
| POST | /v1/transactions/{id}/test/simulate-payment | Test key only — marks the transaction paid instantly, no gateway involved |
| POST | /v1/transactions/{id}/seller-confirmation | Mark shipped |
| POST | /v1/transactions/{id}/verify-delivery | Confirm delivery from your system — no code required |
| POST | /v1/transactions/{id}/confirm | Confirm receipt on the buyer's behalf — releases funds |
| POST | /v1/transactions/{id}/disputes | Open a dispute on the buyer's behalf |
| GET | /v1/transactions/{id}/timeline | Get stage timestamps for a transaction |
With a test key, payment_url still resolves to the hosted guest page, but paying there (or calling simulate-payment directly) marks it paid instantly with no real charge — use whichever is closer to how your integration actually drives payment.
4.1 Create Escrow Transaction
POST /v1/transactions
Request body
{
"buyer": {
"name": "John Doe",
"email": "john@email.com"
},
"amount": 85000,
"currency": "NGN",
"reference": "ORDER-1001",
"product": "Samsung Galaxy S24",
"description": "128GB, Black",
"buyerWhatsapp": "08012345678"
}
referenceis your order/idempotency key. Replaying the same(merchant, reference)pair returns the original transaction instead of creating a duplicate — safe to retry on a network timeout.buyerWhatsappis optional. If provided, the buyer gets reminders and can act on their own viapayment_url; if omitted, your system is expected to drive the whole lifecycle — includingverify-deliveryandconfirm— via API calls.- Only
"NGN"is currently supported.
Response 201
{
"transaction_id": "TX100001",
"payment_url": "https://xcrowpay.com/t/eyJ...",
"status": "CREATED"
}
payment_url is a hosted, no-login page where the buyer can pay, verify delivery, confirm, or dispute directly — send your customer here if you don't want to build your own UI for these steps.
4.2 Retrieve Transaction
GET /v1/transactions/{id}
Returns the full transaction record (status, amounts, timestamps). Returns 404 if the transaction doesn't exist or doesn't belong to your merchant account.
4.3 Mark as Shipped
POST /v1/transactions/{id}/seller-confirmation
Call this once the order has shipped. Requires the transaction to be PAID. If buyerWhatsapp was provided, this also notifies the buyer over WhatsApp and returns a delivery_link you can relay through your own channels — but neither is required for the API flow below to work.
Response
{ "status": "DELIVERY_PENDING", "transaction_id": "TX100001", "delivery_link": "https://xcrowpay.com/t/eyJ..." }
4.4 Confirm Delivery
POST /v1/transactions/{id}/verify-delivery
No request body, and no code involved. This is a pure server-to-server integration — there's no assumption that a human buyer is on the other end typing anything in. How you determine that delivery happened is entirely up to your own system: courier/tracking webhook, an internal ops review, a confirmation button in your own app, whatever fits your business. The moment your backend calls this endpoint with your authenticated secret key, XcrowPay treats delivery as confirmed — your API credentials are the proof, not a code.
Requires the transaction to be DELIVERY_PENDING. Moves it to DELIVERY_VERIFIED.
(The hosted payment_url guest page still uses a code for its own human-facing flow — that's unrelated and unaffected. It exists only for buyers who pay and manage the transaction themselves through the no-login page instead of through your integration.)
4.5 Confirm Receipt (Release Funds)
POST /v1/transactions/{id}/confirm
Call this once you're ready to release funds — typically right after verify-delivery, or later if you want your own separate approval step first. Releases the escrowed funds to your connected bank account. Requires the transaction to be DELIVERY_VERIFIED.
If a transaction sits in DELIVERY_VERIFIED with no confirm call and no buyer action (on the guest page, if one exists) for an extended period, XcrowPay support reviews it directly rather than releasing automatically — see §6.
4.6 Open a Dispute
POST /v1/transactions/{id}/disputes
{ "reason": "Wrong item received", "description": "Received wrong size." }
Escrowed funds stay locked while a dispute is under review. XcrowPay support resolves disputes manually (release to you, or refund to the buyer) after reviewing evidence — there is no automated resolution.
4.7 Transaction Timeline
GET /v1/transactions/{id}/timeline
{
"status": "COMPLETED",
"timeline": {
"created_at": "...", "paid_at": "...", "shipped_at": "...",
"delivery_verified_at": "...", "disputed_at": null,
"completed_at": "...", "refunded_at": null
}
}
5. Transaction Statuses
| Status | Meaning |
|---|---|
CREATED / pending | Transaction created, awaiting buyer payment |
PAID / paid | Buyer paid, funds held in escrow |
DELIVERY_PENDING / delivery_pending | Marked shipped, awaiting delivery confirmation |
DELIVERY_VERIFIED / delivery_verified | Delivery confirmed, awaiting release via /confirm |
DISPUTE_OPEN / disputed | Dispute open, funds locked, awaiting support review |
COMPLETED / completed | Funds released to merchant |
REFUNDED / refunded | Funds refunded to buyer |
(The bare-lowercase form is what you'll see in GET responses; the upper-snake form is what action endpoints echo back — both refer to the same state.)
6. Webhooks
Configure your webhook URL via PATCH /v1/merchants/webhook. Every event is POSTed as:
{ "event": "payment.success", "data": { "transaction_id": "TX100001", "status": "PAID" } }
| Event | Fired when |
|---|---|
escrow.created | A transaction is created |
payment.success | The buyer's payment is confirmed |
escrow.updated | The transaction moves to DELIVERY_PENDING |
delivery.verified | Delivery is confirmed via verify-delivery |
buyer.disputed | A dispute is opened |
funds.released | Escrowed funds are released to you (whether triggered by the buyer, by you via /confirm, or by XcrowPay support after manual review) |
refund.completed | Escrowed funds are refunded to the buyer following dispute resolution |
payment.failed is not currently emitted — there is no failed-charge webhook wired up from the payment gateway yet.
Verifying signatures
Every webhook request includes an X-Xcrow-Signature header: an HMAC-SHA256 of the raw JSON body, using your webhook_secret.
const crypto = require('crypto');
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
const isValid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(receivedSignature));
Reject any request whose signature doesn't match. Delivery is best-effort with one retry — there is currently no durable retry queue, so treat GET /v1/transactions/{id} as the source of truth if you suspect a missed webhook, and poll it after a timeout rather than assuming a webhook will eventually arrive.
Manual release — how disputes without a response are actually handled
This is a deliberate product decision, not a placeholder: XcrowPay does not auto-release escrowed funds. If the buyer verifies delivery and then goes silent:
- After the 3-hour confirmation window, the buyer gets one WhatsApp reminder (if a WhatsApp number was provided).
- After a further 3 hours of silence, XcrowPay support is notified to review and release the funds manually.
You don't need to build anything for this — it's handled on XcrowPay's side — but it does mean funds.released for a non-responsive buyer may arrive several hours after delivery.verified, not instantly.
7. Errors
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created |
| 400 | Bad request / invalid state transition |
| 401 | Missing or invalid API key |
| 403 | KYC tier or daily limit exceeded |
| 404 | Resource not found, or not owned by your merchant account |
| 409 | Duplicate (e.g. dispute already exists for this transaction) |
| 422 | Validation error (e.g. non-positive amount) |
| 429 | Rate limited — see §8 |
| 500 | Internal error |
Error responses are always { "message": "..." }, and where applicable a machine-readable reason (e.g. KYC_TIER_UPGRADE_REQUIRED).
8. Security & Operational Notes
- HTTPS only. All endpoints are served over HTTPS; there is no HTTP fallback.
- Rate limiting: 120 requests/minute per merchant across all
/v1/transactions*endpoints. Session-authenticated merchant management routes (register-existing,rotate-secret,live-key/reveal,webhookconfig) are limited to 20 requests/15 minutes per account; verificationupload/submitto 30 requests/hour per account. Exceeding either returns429. - Idempotency: transaction creation is idempotent on
(merchant, reference)— see §4.1. Other endpoints (confirm,verify-delivery, etc.) are naturally idempotent because they're gated by the transaction's current status: callingconfirmtwice on an already-COMPLETEDtransaction returns an error rather than double-releasing funds. - Webhook signature verification is mandatory on your end — see §6.
- No IP allowlisting yet. IP allowlisting for enterprise merchants is not implemented in this version.
- Test mode is your sandbox. There's no separate sandbox environment or base URL — a test secret key against the same endpoints is the sandbox (see "Test vs. live keys" in §2). Nothing in test mode is billed, charged, or paid out.
9. What's Not in v1
To set expectations clearly, the following are not built yet (tracked as future work, matching the original product roadmap):
- IP allowlisting for enterprise merchants
payment.failedwebhook- Durable webhook retry queue (current retry is one immediate reattempt, not a queue)
- Partial/milestone-based escrow releases
- Multi-party escrow, split settlements, multi-currency
- OAuth 2.0, public API analytics dashboard, bulk transaction creation