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 keyLive key
    AvailableImmediately at registrationOnly after business verification is approved (§2.2)
    Money movementNone — fully simulated, never touches KorapayReal, via Korapay
    KYC/tier limitsNot enforcedEnforced (§3)
    WhatsApp messagesNever sentSent normally
    WebhooksFire 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_key and webhook_secret are shown once, here, and never again. Store them immediately in your own secrets manager. If lost, rotate via POST /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)

    MethodEndpointPurpose
    GET/v1/merchants/meView 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/revealReveal your live secret key once, right after approval (see §2.2)
    PATCH/v1/merchants/webhookSet/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 TypeRequired documents
    individualProof of address (utilityBill)
    registered_business (sole proprietorship)cacCertificate, utilityBill
    registered_business + isLimitedLiability: true (LTD/LLC)cacCertificate, memart, utilityBill
    ngocacCertificate (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/reveal becomes 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:

    TierDaily 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

    MethodEndpointDescription
    POST/v1/transactionsCreate an escrow transaction
    GET/v1/transactions/{id}Retrieve a transaction
    POST/v1/transactions/{id}/test/simulate-paymentTest key only — marks the transaction paid instantly, no gateway involved
    POST/v1/transactions/{id}/seller-confirmationMark shipped
    POST/v1/transactions/{id}/verify-deliveryConfirm delivery from your system — no code required
    POST/v1/transactions/{id}/confirmConfirm receipt on the buyer's behalf — releases funds
    POST/v1/transactions/{id}/disputesOpen a dispute on the buyer's behalf
    GET/v1/transactions/{id}/timelineGet 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"
    }
    
    • reference is 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.
    • buyerWhatsapp is optional. If provided, the buyer gets reminders and can act on their own via payment_url; if omitted, your system is expected to drive the whole lifecycle — including verify-delivery and confirm — 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

    StatusMeaning
    CREATED / pendingTransaction created, awaiting buyer payment
    PAID / paidBuyer paid, funds held in escrow
    DELIVERY_PENDING / delivery_pendingMarked shipped, awaiting delivery confirmation
    DELIVERY_VERIFIED / delivery_verifiedDelivery confirmed, awaiting release via /confirm
    DISPUTE_OPEN / disputedDispute open, funds locked, awaiting support review
    COMPLETED / completedFunds released to merchant
    REFUNDED / refundedFunds 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" } }
    
    EventFired when
    escrow.createdA transaction is created
    payment.successThe buyer's payment is confirmed
    escrow.updatedThe transaction moves to DELIVERY_PENDING
    delivery.verifiedDelivery is confirmed via verify-delivery
    buyer.disputedA dispute is opened
    funds.releasedEscrowed funds are released to you (whether triggered by the buyer, by you via /confirm, or by XcrowPay support after manual review)
    refund.completedEscrowed 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:

    1. After the 3-hour confirmation window, the buyer gets one WhatsApp reminder (if a WhatsApp number was provided).
    2. 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

    CodeMeaning
    200Success
    201Created
    400Bad request / invalid state transition
    401Missing or invalid API key
    403KYC tier or daily limit exceeded
    404Resource not found, or not owned by your merchant account
    409Duplicate (e.g. dispute already exists for this transaction)
    422Validation error (e.g. non-positive amount)
    429Rate limited — see §8
    500Internal 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, webhook config) are limited to 20 requests/15 minutes per account; verification upload/submit to 30 requests/hour per account. Exceeding either returns 429.
    • 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: calling confirm twice on an already-COMPLETED transaction 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.failed webhook
    • 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