Marketplace docs

adan. Delivery Assurance API

Embed conditional payments into your checkout. Buyers pay with confidence — funds release only after the receiver confirms delivery. You keep your marketplace; Amana provides the trust layer.

View endpointsWebhooksPreview · Partner API

Overview

Amana sits between your checkout and settlement. You create a transaction, the buyer funds it, Amana holds the money, and release happens when delivery is confirmed — or after dispute resolution.

Hold, don’t just charge

Payment is reserved until real-world delivery conditions are met.

Event-driven sync

Webhooks keep your order system aligned without polling.

Thin integration

A handful of endpoints cover create, confirm, dispute, and status.

Base URL (current preview deployment): https://amana-backend-node.onrender.com/api/v1

Transaction IDs are UUIDs. Hosted checkout lives at https://adana.africa/pay/{shareToken}.

Checkout flow

At checkout, offer Delivery Assurance alongside your existing payment options. Amana handles escrow, confirmation, and dispute locking.

  1. 1

    Buyer checks out

    Your marketplace offers “Pay with Delivery Assurance” beside Pay Now / COD.

  2. 2

    Create transaction

    Call POST /api/v1/transactions. Amana returns a hosted checkoutUrl.

  3. 3

    Funds held

    Buyer pays. You receive payment.funded and can redirect via successUrl.

  4. 4

    Confirm & settle

    Use deliveryOtp from GET, then POST …/confirm — or let the buyer confirm in-app.

Sequence
Marketplace Checkout
  → POST /api/v1/transactions
  → Redirect buyer to checkoutUrl (/pay/{shareToken})
  → Buyer transfers to escrow account
  → webhook: payment.funded
  → Seller ships (use deliveryOtp from GET)
  → POST /api/v1/transactions/{id}/confirm  OR buyer confirms in Amana
  → webhook: receiver.confirmed
  → webhook: escrow.released

Authentication

Authenticate every Partner API request with a live secret key in the Authorization header. Keys are shown once when created in Developers — store them on your server only.

HTTP header
Authorization: Bearer ak_live_••••••••
Content-Type: application/json
  • Only ak_live_ keys are issued and accepted today.
  • There is no sandbox environment and no ak_test_ keys yet — use your approved live key against the preview API carefully.
  • Transaction id values are UUIDs (not short txn_* codes).

Endpoints

Paths below are relative to the base URL. Full create path: POST https://amana-backend-node.onrender.com/api/v1/transactions.

POST/transactions

Create transaction

Creates a protected payment. Funds are held until the receiver confirms delivery. Response bodies are wrapped in a top-level data object. Pass a stable externalReference (or metadata.orderId) so retries return the same transaction instead of creating a duplicate.

Request body
{
  "amount": 25000,
  "currency": "NGN",
  "buyer": {
    "email": "buyer@example.com",
    "name": "Ada Okonkwo"
  },
  "description": "Order #4821 — Wireless earbuds",
  "externalReference": "4821",
  "metadata": {
    "orderId": "4821",
    "marketplaceId": "mp_lagos_01"
  },
  "successUrl": "https://yoursite.com/orders/4821/paid",
  "cancelUrl": "https://yoursite.com/checkout"
}
Response
{
  "data": {
    "id": "a3f1c8e2-4b9d-4e21-9c7a-0d5e8f1a2b3c",
    "reference": "INV-20260711-A1B2C3",
    "status": "Awaiting Payment",
    "paymentStatus": "Unpaid",
    "deliveryStatus": "Not Started",
    "checkoutUrl": "https://adana.africa/pay/share_token_here",
    "confirmationUrl": "https://adana.africa/pay/share_token_here",
    "successUrl": "https://yoursite.com/orders/4821/paid",
    "cancelUrl": "https://yoursite.com/checkout",
    "requiresDeliveryOtp": false,
    "deliveryOtp": null,
    "amount": "25000.00",
    "currency": "NGN",
    "externalReference": "4821"
  },
  "meta": {
    "idempotentReplay": false,
    "externalReference": "4821"
  }
}
GET/transactions/{id}

Get transaction

Returns the current assurance state. While funds are in escrow (or disputed), requiresDeliveryOtp is true and deliveryOtp is the 6-digit code for POST …/confirm (or courier handoff).

Response
{
  "data": {
    "id": "a3f1c8e2-4b9d-4e21-9c7a-0d5e8f1a2b3c",
    "reference": "INV-20260711-A1B2C3",
    "status": "Awaiting Receiver Confirmation",
    "paymentStatus": "Escrow",
    "deliveryStatus": "Unknown",
    "amount": "25000.00",
    "currency": "NGN",
    "requiresDeliveryOtp": true,
    "deliveryOtp": "482193",
    "timestamps": {
      "createdAt": "2026-07-11T10:00:00.000Z",
      "fundedAt": "2026-07-11T10:04:12.000Z",
      "confirmedAt": null,
      "releasedAt": null
    },
    "dispute": null
  }
}
GET/transactions/{id}/delivery-otp

Get delivery OTP

Fetch only the active delivery OTP. Useful right before handoff. Returns 400 if the transaction is not in escrow (or already confirmed).

Response
{
  "data": {
    "transactionId": "a3f1c8e2-4b9d-4e21-9c7a-0d5e8f1a2b3c",
    "reference": "INV-20260711-A1B2C3",
    "status": "paid_in_escrow",
    "requiresDeliveryOtp": true,
    "deliveryOtp": "482193"
  }
}
POST/transactions/{id}/confirm

Confirm receipt

Records receiver confirmation with deliveryOtp from GET (or payment.funded webhook), then releases escrowed funds. confirmedBy must match the buyer email.

Request body
{
  "confirmedBy": "buyer@example.com",
  "deliveryOtp": "482193",
  "latitude": 6.5244,
  "longitude": 3.3792
}
Response
{
  "message": "Receipt confirmed with delivery OTP. Funds have been released to the seller.",
  "data": {
    "id": "a3f1c8e2-4b9d-4e21-9c7a-0d5e8f1a2b3c",
    "status": "Completed",
    "paymentStatus": "Released",
    "deliveryStatus": "Confirmed",
    "requiresDeliveryOtp": false,
    "deliveryOtp": null
  }
}
POST/transactions/{id}/disputes

Open dispute

Locks funds and opens a dispute while payment is held in escrow. Opening a second dispute returns HTTP 400.

Request body
{
  "reason": "items_not_received",
  "description": "Package marked delivered but buyer did not receive it."
}
Response
{
  "message": "Dispute opened successfully.",
  "data": {
    "dispute": {
      "id": "91bc4e10-6f2a-4c8d-9e1b-7a5d3f0c2e4b",
      "transactionId": "a3f1c8e2-4b9d-4e21-9c7a-0d5e8f1a2b3c",
      "status": "open",
      "reason": "items_not_received"
    },
    "transaction": {
      "id": "a3f1c8e2-4b9d-4e21-9c7a-0d5e8f1a2b3c",
      "status": "Disputed",
      "paymentStatus": "Locked",
      "deliveryOtp": "482193"
    }
  }
}
GET/transactions/{id}/disputes/{disputeId}/messages

List dispute messages

Read the buyer ↔ partner ↔ ops thread for an open dispute on your transaction.

Response
{
  "data": {
    "disputeId": "91bc4e10-6f2a-4c8d-9e1b-7a5d3f0c2e4b",
    "canSend": true,
    "uploadsEnabled": true,
    "messages": [
      {
        "id": "…",
        "body": "Package photo attached",
        "sender": { "id": "…", "name": "Lagos Market", "role": "partner" },
        "attachment": {
          "url": "https://res.cloudinary.com/da8syamxo/image/upload/…",
          "publicId": "amana/disputes/91bc4e10-…/photo",
          "fileName": "box.jpg"
        },
        "createdAt": "2026-07-11T11:00:00.000Z"
      }
    ]
  }
}
POST/transactions/{id}/disputes/{disputeId}/uploads/sign

Sign evidence upload

Returns a Cloudinary signed upload payload. Upload the file to uploadUrl, then POST the resulting secure_url/public_id as a message attachment.

Request body
{
  "resourceType": "image",
  "fileName": "damaged-box.jpg"
}
Response
{
  "data": {
    "cloudName": "da8syamxo",
    "apiKey": "••••",
    "timestamp": 1784079000,
    "signature": "…",
    "folder": "amana/disputes/91bc4e10-…",
    "resourceType": "image",
    "uploadUrl": "https://api.cloudinary.com/v1_1/da8syamxo/image/upload"
  }
}
POST/transactions/{id}/disputes/{disputeId}/messages

Post dispute message / evidence

Add a text note and/or Cloudinary attachment to the dispute thread. Requires at least body or attachment.

Request body
{
  "body": "Delivery photo from courier",
  "attachment": {
    "url": "https://res.cloudinary.com/da8syamxo/image/upload/v1/amana/disputes/91bc…/photo.jpg",
    "publicId": "amana/disputes/91bc4e10-…/photo",
    "resourceType": "image",
    "fileName": "photo.jpg",
    "bytes": 184422
  }
}
Response
{
  "message": "Message sent",
  "data": {
    "id": "…",
    "body": "Delivery photo from courier",
    "sender": { "role": "partner", "name": "Lagos Market" },
    "attachment": { "url": "https://res.cloudinary.com/…", "fileName": "photo.jpg" }
  }
}

Statuses

Partner-facing fields project from Amana’s internal invoice states.

InternalstatuspaymentStatusdeliveryStatus
pendingAwaiting PaymentUnpaidNot Started
payment_initiatedPayment In ProgressInitiatedNot Started
paid_in_escrowAwaiting Receiver ConfirmationEscrowUnknown
disputedDisputedLockedUnknown
releasedCompletedReleasedConfirmed
cancelledRefundedRefundedUnknown

Webhooks

Register an HTTPS endpoint in Developers after partner access is approved. Amana POSTs signed JSON events so you can update orders without polling. Failed deliveries retry with exponential backoff (30s → 24h, up to 8 attempts), then move to a dead-letter queue you can inspect and retry from Developers → Delivery dashboard.

Event catalog

  • transaction.createdTransaction is created from your checkout
  • payment.initiatedBuyer starts payment on the hosted checkout
  • payment.fundedFunds held in escrow — payload includes deliveryOtp + requiresDeliveryOtp
  • receiver.confirmedReceiver confirms delivery (OTP verified)
  • escrow.releasedFunds released to the seller (confirm or seller-favour dispute)
  • dispute.openedA dispute is opened on the transaction
  • dispute.under_reviewOps marks the dispute under_review
  • dispute.resolvedOps resolves for seller (resolved_seller) or buyer (resolved_buyer)
  • dispute.closedDispute closed with no payout change — invoice returns to escrow
  • refund.processingBuyer-favour refund transfer queued (not finished yet)
  • refund.completedBuyer-favour refund finishes successfully

Request shape

Every delivery is POST with Content-Type: application/json. The body is a single event object. Extra headers identify the delivery for logging and retries.

Delivery headers
Content-Type: application/json
User-Agent: Amana-Webhooks/1.0
Amana-Signature: t=1710000000,v1=9f8e…hex…
X-Amana-Delivery-Id: <uuid of this delivery row>
X-Amana-Event-Id: evt_<16 hex chars>
X-Amana-Attempt: 1
Example — payment.funded
{
  "id": "evt_3k9m1a2b3c4d5e6f",
  "type": "payment.funded",
  "createdAt": "2026-07-11T10:04:12.000Z",
  "data": {
    "transactionId": "a3f1c8e2-4b9d-4e21-9c7a-0d5e8f1a2b3c",
    "reference": "INV-20260711-A1B2C3",
    "amount": "25000.00",
    "currency": "NGN",
    "status": "Awaiting Receiver Confirmation",
    "paymentStatus": "Escrow",
    "deliveryStatus": "Unknown",
    "externalReference": "4821",
    "metadata": { "orderId": "4821" },
    "deliveryOtp": "482193",
    "requiresDeliveryOtp": true
  }
}
Example — dispute.under_review / dispute.closed / refund.processing
// dispute.under_review
{
  "id": "evt_…",
  "type": "dispute.under_review",
  "createdAt": "2026-07-11T12:00:00.000Z",
  "data": {
    "transactionId": "a3f1c8e2-…",
    "reference": "INV-…",
    "status": "Disputed",
    "paymentStatus": "Locked",
    "disputeId": "91bc4e10-…"
  }
}

// dispute.closed — funds stay / return to escrow (no refund, no seller release)
{ "type": "dispute.closed", "data": { "transactionId": "…", "disputeId": "…" } }

// refund.processing — buyer-favour payout queued; wait for refund.completed
{ "type": "refund.processing", "data": { "transactionId": "…", "disputeId": "…" } }

Verifying Amana-Signature

  1. Read the Amana-Signature header. Format is exactly t=<unix_seconds>,v1=<hex>.
  2. Build the signed string as t + "." + rawBody where rawBody is the exact HTTP body bytes Amana sent (do not pretty-print or re-stringify JSON first).
  3. Compute HMAC-SHA256(webhook_secret, signed_string) as lowercase hex.
  4. Compare to v1 with a timing-safe equals. Also reject stale timestamps (recommended tolerance: 5 minutes).
  5. Your webhook secret was shown once when access was approved (or when you rotated it in Developers). Store it server-side only.
Node.js verify helper
import { createHmac, timingSafeEqual } from "node:crypto";

/**
 * Amana signs: HMAC-SHA256(webhookSecret, `${t}.${rawBody}`)
 * Header: Amana-Signature: t=<unix_seconds>,v1=<hex>
 * Always verify against the raw request body bytes (not re-serialized JSON).
 */
export function verifyAmanaWebhook(input: {
  signatureHeader: string | null | undefined;
  rawBody: string | Buffer;
  webhookSecret: string;
  /** Reject if |now - t| exceeds this (seconds). Default 5 minutes. */
  toleranceSeconds?: number;
}) {
  const header = input.signatureHeader ?? "";
  const parts = Object.fromEntries(
    header.split(",").map((part) => {
      const [k, ...rest] = part.trim().split("=");
      return [k, rest.join("=")];
    }),
  );

  const t = parts.t;
  const v1 = parts.v1;
  if (!t || !v1) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(age) || age > (input.toleranceSeconds ?? 300)) {
    return false;
  }

  const payload = `${t}.${typeof input.rawBody === "string" ? input.rawBody : input.rawBody.toString("utf8")}`;
  const expected = createHmac("sha256", input.webhookSecret)
    .update(payload, "utf8")
    .digest("hex");

  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(v1, "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express tip: use express.raw({ type: "application/json" }) for this route,
// then JSON.parse(req.body.toString("utf8")) after verification.

Respond with 2xx within ~10 seconds after verification. Non-2xx or timeouts are retried. Use X-Amana-Event-Id / id for idempotent processing.

Hosted checkout

There is no embeddable payment widget yet. Redirect buyers to the hosted pay page returned as checkoutUrl, then sync order state with webhooks (and optional success/cancel return URLs).

Hosted checkout

Redirect buyers to checkoutUrl. After funds hit escrow (or release), Amana sends them to successUrl with query params amana_transaction_id, amana_reference, and amana_status. Use cancelUrl for abandon flows.

API confirmation

After payment.funded, read deliveryOtp from the webhook (or GET /transactions/{id} / …/delivery-otp), share it with logistics, then call POST …/confirm from your server.

Minimal server example
const res = await fetch("https://amana-backend-node.onrender.com/api/v1/transactions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AMANA_SECRET_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    amount: 25000,
    currency: "NGN",
    buyer: { email: order.buyerEmail, name: order.buyerName },
    externalReference: String(order.id),
    metadata: { orderId: order.id },
    successUrl: `https://yoursite.com/orders/${order.id}/paid`,
    cancelUrl: "https://yoursite.com/checkout",
  }),
});

const { data } = await res.json();
// Redirect the buyer to data.checkoutUrl

Errors

Errors use NestJS JSON shape with standard HTTP status codes — not a custom { error: { code } } envelope.

Error shape
{
  "statusCode": 400,
  "message": "Payment must be held in escrow before confirmation",
  "error": "Bad Request"
}
  • 400 — Invalid request, illegal state transition, or dispute already open
  • 401 — Missing or invalid API key
  • 403 — Authenticated but not allowed for this resource
  • 404 — Transaction not found
  • 409 externalReference already used for a different amount/buyer/currency

Ready to integrate?

Enable seller tools on a verified account, then request partner API access from Developers. Once approved, create a live key, register your webhook URL, and start with hosted checkout.