Devzity
Back to Home
Skip to content
SDK preview. Packages are not publicly published yet. Use REST / cURL, or the local development artifacts.

Introduction

Markdown

Accept bKash and Nagad payments with a few lines of code — and try everything in test mode first.

Devzity lets your website or app accept bKash and Nagad payments. You create a Payment from your server, send your customer to its checkout page, and Devzity tells you when it is paid.

Text
Your server ──(secret key)──▶ Devzity API ──▶ checkout_url ──▶ your customer pays
                                   │
                                   └──▶ signed webhook ──▶ your server fulfils the order

Start in test mode#

Test mode moves no real money and needs no bKash or Nagad account, no Devzity receiving setup and no Service Credit. You can create a payment, complete it with one call, and receive a signed webhook in a few minutes.

What you can use#

Node.js SDKNode.js / TypeScript servers
Next.js helpersNext.js App Router routes and webhooks
RESTPHP, Laravel, Java / Spring Boot, Go, Python, curl — anything that speaks HTTP; available today
Android and iOS checkout SDKsOpening checkout from a native app and handling the return
React Native and FlutterThe same pattern with standard link handling

Concepts in one minute#

  • Payment — one request to collect an amount. It starts PENDING and ends PAID, CANCELLED or REVERSED.
  • Checkout — the hosted page your customer pays on. You never handle their payment details.
  • Webhook — a signed message Devzity sends your server when something happens. It is how you learn a payment was paid.
  • Test vs Live — two isolated environments with different secret keys (dz_test_sk_…, dz_live_sk_…).

See Versioning and stability. The API version is v1 (/v1/...). It is separate from SDK package versions (@devzity/sdk 0.x): the docs at /v1 describe the v1 API, whichever SDK version you use.

Quickstart

Markdown

Accept your first test payment in a few minutes — no real money, no payment account.

The whole idea#

  1. Use test mode. It needs no bKash or Nagad account.
  2. Create a test secret key in the dashboard.
  3. The secret key lives on your server, never in browser or app code.
  4. Create a Payment from your server.
  5. Open its checkoutUrl for the customer.
  6. Simulate success with one call.
  7. Verify the webhook Devzity sends your server.

Building a mobile app? Your app talks to your small backend, and that backend talks to Devzity — start with your platform below.

Choose your platform#

The steps below are for Node.js, Next.js and any server that speaks HTTP. Other platforms follow the same six steps in their own guide.

1. Create a test secret key#

In the dashboard open Developers → API keys → Create secret key, choose Test, and copy the key (it starts with dz_test_sk_). It is shown once. Keep it on your server:

Shell
export DEVZITY_SECRET_KEY=dz_test_sk_...
export DEVZITY_API_URL=https://api.devzity.com

2. Get the SDK (or use REST)#

Node.js

Next.js

You can always skip the SDK and call the REST API directly — the steps are identical.

3. Create a payment#

create-payment.mjs · JavaScript
// @devzity/sdk quickstart — run on your SERVER (Node.js 18+).
import { Devzity } from "@devzity/sdk";

const devzity = new Devzity({
  secretKey: process.env.DEVZITY_SECRET_KEY,
  baseUrl: process.env.DEVZITY_API_URL, // @preview-only — not needed once the SDK ships with the production URL built in
});

const payment = await devzity.payments.create({
  amount: 500,
});

console.log(payment.checkoutUrl);

amount: 500 is fine for a simple value. For an exact amount such as ৳500.50, pass a string: amount: "500.50". The SDK adds a secure idempotency key for you, so a retried request never creates a second payment.

Prefer plain HTTP?

Shell
#!/usr/bin/env bash
# Create a TEST payment with plain curl. Needs a Test secret key from
# Developers → API keys (it starts with dz_test_sk_). No real money is involved.
set -euo pipefail
: "${DEVZITY_SECRET_KEY:?Set DEVZITY_SECRET_KEY to your dz_test_sk_... key}"
: "${DEVZITY_API_URL:?Set DEVZITY_API_URL to the Devzity API base URL you were given}"

curl "$DEVZITY_API_URL/v1/payments" \
  -X POST \
  -H "Authorization: Bearer $DEVZITY_SECRET_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"amount":"500.00"}'

4. Open the checkoutUrl#

Send your customer to payment.checkoutUrl: redirect them from your server, or return it to your mobile app to open (Android, iOS). The test checkout page shows a TEST MODE banner and a fake receiving number.

5. Simulate the payment#

No real money moves in test mode. Complete the payment with one call — or press Simulate successful payment on the test checkout page:

JavaScript
await devzity.testHelpers.payments.succeed(payment.id)

6. Receive the webhook#

In Developers → Webhooks → Add endpoint register your server's URL for the Test environment and copy its signing secret (whsec_…) into DEVZITY_WEBHOOK_SECRET.

JavaScript
// Receive Devzity webhooks with @devzity/sdk (Node.js 18+, no framework).
import { createServer } from "node:http";
import { DevzitySignatureVerificationError, verifyWebhook } from "@devzity/sdk";

// Demo only: Devzity delivers each event AT LEAST once, so remember the ids you have handled.
// In production use your database (a table with a unique event_id) instead of a Set.
const processed = new Set();

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/devzity/webhook") {
    res.writeHead(404).end();
    return;
  }

  // 1. Read the RAW body — do not JSON.parse before verifying.
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks);

  // 2. Verify the signature. Never trust an unverified webhook.
  let event;
  try {
    event = verifyWebhook(rawBody, req.headers["devzity-signature"], process.env.DEVZITY_WEBHOOK_SECRET);
  } catch (error) {
    if (error instanceof DevzitySignatureVerificationError) {
      res.writeHead(400).end();
      return;
    }
    throw error;
  }

  // 3. Already handled this event? Acknowledge and stop — never fulfil twice.
  if (processed.has(event.id)) {
    res.writeHead(200).end();
    return;
  }

  // 4. Fulfil ONLY from a verified payment.paid event. Ignore anything you don't recognise.
  if (event.type === "payment.paid") {
    console.log("Fulfil the order for payment", event.data.object.id);
  }
  processed.add(event.id);

  // 5. Any 2xx acknowledges the event. Don't rely on delivery order.
  res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ received: true }));
}).listen(Number(process.env.PORT ?? 3000));

The example verifies the signature, ignores an event it has already handled, and fulfils only from payment.paid. Details: Webhooks.

You're done#

If everything worked you now have:

Your console.loga checkoutUrl
The paymentstatus: PAID after step 5
Your webhook endpointa verified payment.paid event with livemode: false
The dashboard → Developers → Webhooksthe delivery marked Delivered

That is a complete integration. Next: Going live when you're ready for real payments, or Payments and Checkout for the details.

Authentication

Markdown

Secret keys, test and live environments, and where a key may (and may not) live.

Every API request carries a secret key as a bearer token:

http
Authorization: Bearer dz_test_sk_...

A key is bound to one business and one environment. You never send a business id — the key alone decides which business a request belongs to.

dz_test_sk_TestNo — sandbox only
dz_live_sk_LiveYes

Test and Live are completely separate: a test key can neither see nor change a live payment, and the reverse. Create keys under Developers → API keys; the secret is shown once. Lost it? Rotate the key.

Where the key lives#

Text
WEBSITE / SERVER
your server ──(secret key)──▶ Devzity API ──▶ checkout_url ──▶ customer

ANDROID / iOS / REACT NATIVE / FLUTTER
mobile app ──▶ YOUR backend or serverless function ──(secret key)──▶ Devzity API
mobile app ◀── checkout_url ── your backend        (the app only opens the URL)

The same shape covers a serverless function or a Next.js route: the function holds the key, the app never does.

Scopes#

A key has payments:read (retrieve and list) and/or payments:write (create and cancel). They are independent. A request outside a key's scopes returns insufficient_scope (403).

Failures#

An unknown, malformed or revoked key returns 401 invalid_api_key. Requests are rate limited per key (60 per minute by default); a 429 includes a Retry-After header. See Errors.

Build with an AI assistant

Markdown

Point Claude, Cursor, Copilot or any AI coding assistant at these docs, and give it the rules a Devzity integration must follow.

These docs are written to be read by AI coding assistants as well as people. Give your assistant the Markdown below, then the rules, and it can build a correct integration.

Give your assistant the docs#

Index of every page (the llms.txt standard)https://docs.devzity.com/llms.txt
Every page in one filehttps://docs.devzity.com/llms-full.txt
One page as Markdownhttps://docs.devzity.com/v1/<page>.md, for example https://docs.devzity.com/v1/webhooks.md
The API as OpenAPI 3https://docs.devzity.com/openapi/devzity-v1.yaml
  • Claude Code, Cursor, Windsurf: paste the llms-full.txt address into the chat, or add it as a docs source, and ask it to read it before writing code.
  • ChatGPT, Gemini, Copilot Chat: paste the Markdown of the pages you need (Quickstart, Webhooks, your platform page).
  • Code generators: generate a client from the OpenAPI document, or use the official SDK from the Quickstart.

The rules: paste these into your assistant#

Put this block in your project's assistant instructions (CLAUDE.md, AGENTS.md, .cursorrules, or Copilot's custom instructions). Each rule links to the page that explains it.

Text
Devzity integration rules (docs: https://docs.devzity.com/llms.txt)

1. The secret key (dz_test_sk_… / dz_live_sk_…) lives only on the server, read from an
   environment variable. Never put it in a browser, mobile app, repository or log.
2. Create payments only from the server: POST https://api.devzity.com/v1/payments (or the SDK),
   with an Idempotency-Key header that is unique per order attempt and reused on retry.
3. Amounts are taka as a decimal string with at most two decimals ("500.00").
   Never compute money with floating point.
4. Send the customer to the payment's checkout_url. Do not build your own bKash/Nagad
   payment screen; the customer never types a transaction ID.
5. Never mark an order paid because the customer returned to return_url, or because the
   app or browser said so. devzity_status on the return URL is a hint, not proof.
6. Fulfil an order only after a verified payment.paid webhook, or after the server reads
   GET https://api.devzity.com/v1/payments/{id} and sees status PAID.
7. Verify every webhook: HMAC-SHA256 over "<t>." + the raw request body with the
   endpoint's whsec_ secret, compared in constant time, rejecting a t older than 5 minutes.
   Read the raw bytes; never re-serialize the JSON before verifying.
8. Webhooks arrive at least once and in any order. Store each event id and skip one you
   have already handled. Ignore event types you don't know.
9. Answer the webhook with a 2xx quickly; do slow work afterwards.
10. Build and test everything with a dz_test_sk_ key and a Test webhook endpoint first.
    Switch to live keys only after the Going live checklist.
11. Keep the order <-> payment link with merchant_reference or external_id, and keep your
    own record of what was paid; never trust amounts sent back by the client.
12. Handle errors by their code (see Errors), retry only what is safe to retry, and keep
    the same Idempotency-Key when you do.
13. Use the REST API directly unless the Quickstart shows a real install command for an SDK.
    An SDK marked "preview" is not on npm/Maven yet; do not invent package names or versions.

Ask your assistant to check its work#

After it writes the integration, ask it to confirm each item, then run the Testing flow yourself:

  • No secret key anywhere outside server environment variables.
  • POST /v1/payments sends an Idempotency-Key.
  • The webhook handler verifies Devzity-Signature on the raw body and rejects old timestamps.
  • Orders are fulfilled only from payment.paid (or a server-side status check), once per event id.
  • The return page shows "checking payment…" and asks your server, instead of trusting the URL.
  • A test payment completes end to end in test mode, including the webhook.

Pages your assistant will need most#

Payments

Markdown

Create, retrieve, list and cancel hosted payments; statuses, references, metadata and payment methods.

/v1/payments is the hosted-payment API: each Payment has a Devzity checkout page. (Payments you record by hand in the dashboard, such as cash received, are not part of API v1.)

Create#

JavaScript
const payment = await devzity.payments.create({
  amount: 500,
  externalId: "order-123",
  returnUrl: "https://shop.example/payment-return",
  metadata: { plan: "pro" },
})
amountRequired. Taka as a decimal string with at most two decimals, e.g. "500.00".
external_idYour own id (for example an order id). Unique per business per environment. Creating a second payment with the same value returns external_id_conflict.
merchant_referenceA human reference shown on receipts. Not unique.
descriptionWhat the customer is paying for.
metadataUp to 20 string key/value pairs (keys ≤ 64 characters, values ≤ 500). Never shown to the customer.
return_urlWhere checkout offers to send the customer afterwards. See Checkout.
allowed_methodsRestrict to BKASH and/or NAGAD. Default: all your enabled providers.
allowed_channelsRestrict how the customer may pay (below). Default: every channel you accept.

Amounts#

For an exact value, pass a string: amount: "500.50". A plain number is convenient for simple values (amount: 500). The SDK is deliberately conservative: an amount that can't be represented exactly with at most two decimals — such as 0.1 + 0.2, 0.001, NaN or a huge number — is rejected, never silently rounded. That is a feature: it stops floating-point surprises from becoming wrong charges.

Payment types (channels)#

SEND_MONEYSend Money
PAYMENTPayment
CASH_INCash In
BANK_TRANSFERBank / Card Add Money

Each type is independent. BANK_TRANSFER payments don't ask for the customer's phone number.

Retrieve, list, cancel#

JavaScript
await devzity.payments.retrieve(payment.id)
await devzity.payments.list({ limit: 20, status: "PAID" })
await devzity.payments.cancel(payment.id)   // only while PENDING

GET /v1/payments supports status, external_id, created_from, created_to, limit and starting_after. See Pagination.

Statuses#

PENDINGCreated; waiting for the customer to pay.
PAIDPayment confirmed.
CANCELLEDCancelled before payment.
REVERSEDA paid payment was reversed in Devzity's records. This does not mean money was automatically refunded to the customer by bKash or Nagad.

There is no expired or failed status: a payment stays PENDING until it is paid or you cancel it.

The Payment object#

id, livemode, status, amount, currency (BDT), description, merchant_reference, external_id, metadata, allowed_methods, allowed_channels, return_url, checkout_url, created_at, paid_at, cancelled_at. Every field is documented in the API reference. The SDK returns the same object in camelCase (payment.checkoutUrl, payment.externalId, …).

Checkout

Markdown

Send customers to the hosted checkout page, and handle their return safely.

Checkout is Devzity's hosted payment page. You never build a payment form.

  1. Your server creates a Payment and receives checkout_url.
  2. You send the customer there — an HTTP redirect on the web, or opening the URL from a mobile app.
  3. The customer chooses bKash or Nagad, enters their number if needed, and pays.
  4. If you supplied a return_url, checkout offers a Return to merchant button (or Return to app for native links). It never redirects automatically.
  5. Your server learns the outcome from a webhook or by retrieving the payment.

return_url#

Use an absolute https:// URL for websites, or your app's link for native apps: a custom scheme (myapp://payment-return, com.example.shop://done) or an https App Link / Universal Link. javascript:, data:, file: and intent: are always rejected. Any query parameters you include are preserved.

Devzity appends two parameters:

devzity_payment_idThe Payment id.
devzity_statusPAID or CANCELLED.

Correct handling, in any language:

Text
on return with devzity_status=PAID:
    refresh the order/payment from YOUR backend   ← never fulfil here

Your backend confirms with GET /v1/payments/{id}, or by having received the verified payment.paid webhook.

If the customer never comes back#

Closing the tab, pressing back, or a killed app does not mean the payment failed — they may have already paid. Don't mark anything failed and don't tell the customer to pay again; wait for the webhook, or ask your backend for the payment's status.

Test checkout#

A test payment's checkout shows a TEST MODE — No real money will be moved banner, a fake receiving number (01XXXXXXXXX), and buttons to simulate success or cancellation. See Testing.

Webhooks

Markdown

Receive signed payment events on your server — set up, verify, deduplicate, and handle retries and ordering.

A webhook is an HTTP POST Devzity sends to your server when a payment changes. It is the reliable way to learn that a payment was paid — even if the customer closes their browser.

Set up an endpoint#

  1. Developers → Webhooks → Add endpoint. Choose the environment (Test or Live), enter your HTTPS URL, and pick the events you want (or all of them).
  2. Copy the signing secret (whsec_…). It is shown once; rotate it any time from the endpoint page.
  3. Store it as DEVZITY_WEBHOOK_SECRET on your server.

Production endpoints must be public https URLs. Use Send test event on a Test endpoint to check your receiver.

Test payments go to Test endpoints; live payments to Live endpoints#

Test payment (livemode: false)your Test webhook endpoint(s) only
Live payment (livemode: true)your Live webhook endpoint(s) only

Each endpoint has its own signing secret. A very common mistake is registering only a Live endpoint and wondering why test payments send nothing — register one for each environment you use, and use the secret of the endpoint that received the request.

Events#

payment.createdA payment is created.
payment.paidA payment becomes PAID.
payment.cancelledA payment is cancelled.
payment.reversedA paid payment is reversed in Devzity's records (not an automatic provider refund).
endpoint.testYou pressed Send test event (test endpoints only). Contains no Payment.

Webhooks describe hosted payments, the same objects /v1/payments returns. New event types may be added later: ignore types you don't recognise (the SDK gives you an unknown event you can log and skip).

JSON
{
  "id": "evt_2q8Zc0mRk1nT",
  "object": "event",
  "api_version": "v1",
  "type": "payment.paid",
  "livemode": false,
  "created_at": "2026-09-20T12:00:00Z",
  "data": { "object": { "id": "…", "status": "PAID", "amount": "500.00" } }
}

data.object is exactly what GET /v1/payments/{id} returns — including your external_id, merchant_reference and metadata, so you can match it to your order.

Verify the signature#

Every request carries Devzity-Signature: t=<unix time>,v1=<hex>. The signature is HMAC-SHA256(secret, "<t>." + rawBody) over the exact bytes received — never a re-serialized JSON. Reject timestamps older than 5 minutes, and compare in constant time.

JavaScript
// Receive Devzity webhooks with @devzity/sdk (Node.js 18+, no framework).
import { createServer } from "node:http";
import { DevzitySignatureVerificationError, verifyWebhook } from "@devzity/sdk";

// Demo only: Devzity delivers each event AT LEAST once, so remember the ids you have handled.
// In production use your database (a table with a unique event_id) instead of a Set.
const processed = new Set();

createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/devzity/webhook") {
    res.writeHead(404).end();
    return;
  }

  // 1. Read the RAW body — do not JSON.parse before verifying.
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks);

  // 2. Verify the signature. Never trust an unverified webhook.
  let event;
  try {
    event = verifyWebhook(rawBody, req.headers["devzity-signature"], process.env.DEVZITY_WEBHOOK_SECRET);
  } catch (error) {
    if (error instanceof DevzitySignatureVerificationError) {
      res.writeHead(400).end();
      return;
    }
    throw error;
  }

  // 3. Already handled this event? Acknowledge and stop — never fulfil twice.
  if (processed.has(event.id)) {
    res.writeHead(200).end();
    return;
  }

  // 4. Fulfil ONLY from a verified payment.paid event. Ignore anything you don't recognise.
  if (event.type === "payment.paid") {
    console.log("Fulfil the order for payment", event.data.object.id);
  }
  processed.add(event.id);

  // 5. Any 2xx acknowledges the event. Don't rely on delivery order.
  res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ received: true }));
}).listen(Number(process.env.PORT ?? 3000));

The Node.js, Java, Go and Python verifiers are automatically tested against the shared signature vectors. The PHP verifier implements the same algorithm and has not been run in automated tests yet.

Read the raw body#

Read the body before any JSON parsing, or the signature can't match. Framework notes:

  • Next.js — use verifyDevzityWebhook(request) (it calls request.text()), or call request.text() yourself. Never call request.json() first: the body can only be read once.
  • Express — mount express.raw({ type: "application/json" }) for the webhook route, before any express.json().
  • Laravel — use $request->getContent(), not $request->all() or ->json(), and exclude the route from CSRF verification.
  • Spring Boot — take the body as @RequestBody String rawBody; parse it yourself after verifying, rather than binding to a DTO.

Handle events safely#

  1. Verify the signature (above).
  2. Deduplicate on event.id. If you have already processed that id, return 200 and do nothing.
  3. Fulfil only from a verified payment.paid — then mark the id processed.
Text
if event already processed → return 200
if event.type == "payment.paid" → fulfil the order for event.data.object
record event.id as processed → return 200

Keep processed ids in your database (a unique constraint on event.id makes this race-safe). Delivery is at least once, never exactly once.

Delivery and reliability#

  • Respond 2xx quickly to acknowledge. Redirects are not followed; 3xx, 4xx, 5xx and timeouts (10 s) count as failures.
  • Retries: failed deliveries are retried immediately, then after 30 s, 2 min, 10 min, 30 min, 2 h and 6 h. After that you can resend from the dashboard.
  • Duplicates happen. A retry sends the same id and the same body.
  • Do not rely on arrival order. payment.created may arrive after payment.paid. When your logic is sensitive to order, retrieve the payment (GET /v1/payments/{id}) and act on its current status rather than on the event alone.
  • An endpoint can be disabled at any time; it then receives nothing new. One that keeps failing is flagged Failing in the dashboard.

Test vectors#

To check your own verification code, webhook-signature-vectors.json in the repository lists deterministic (secret, timestamp, body) → signature cases. All Devzity SDKs and examples must reproduce them byte for byte.

Testing

Markdown

Test mode end to end — test keys, test checkout, simulating payments and webhooks, and how test and live stay separate.

Test mode lets you build and demo a complete integration with no real money, no bKash or Nagad account, no Bridge and no Service Credit.

What test mode is#

  • Use a test secret key (dz_test_sk_…). Payments it creates have livemode: false.
  • The test checkout page shows TEST MODE — No real money will be moved and a clearly fake receiving number (01XXXXXXXXX, "Test bKash").
  • Every provider and payment type works: bKash and Nagad, Send Money, Payment, Cash In and Bank / Card Add Money. allowed_methods, allowed_channels and return_url behave exactly as in live mode.
  • Test payments never appear in your real payments list, revenue insights, receipts or Service Credit.

Complete a test payment#

Simulate the outcome — with the SDK, with HTTP, or with the buttons on the test checkout page:

JavaScript
await devzity.testHelpers.payments.succeed(payment.id)   // → PAID
await devzity.testHelpers.payments.cancel(payment.id)    // → CANCELLED

These endpoints (POST /v1/test_helpers/payments/{id}/succeed and /cancel) accept test keys only; a live key gets a plain 404. They run through the normal payment lifecycle, so webhooks are emitted exactly as they would be for a real payment.

Test webhooks#

Register a Test endpoint (Developers → Webhooks) and it receives the events of your test payments, signed with its own secret. A Test payment is never delivered to a Live endpoint, so register a Test endpoint even if you already have a Live one. Send test event sends a harmless endpoint.test event to check your receiver. See Webhooks.

Test and live are isolated#

  • A test key cannot retrieve, list or cancel a live payment (and vice versa) — the answer is 404, as if it didn't exist.
  • Test webhook events go only to Test endpoints, live events only to Live endpoints.
  • Keys, webhook endpoints and events in the dashboard are always shown for one environment at a time.

Test payments currently do not generate downloadable receipts.

When your integration works, read Going live.

Going live

Markdown

A checklist for real payments — receiving account, detection, Service Credit — and how to switch from test to live safely.

Test mode needs nothing financial. Live mode collects real money into your own bKash or Nagad account, so a few things must be ready first. You set them up in the Devzity dashboard; the API tells you what's missing. Test setup does not carry over to live — each item below is configured separately.

Checklist#

  • Switch to a Live secret key (Developers → API keys → Live) and deploy with it. Test and live data never mix.
  • Configure your payment account(s) — a bKash or Nagad number you own, set as the active receiving account for that provider.
  • Verify the payment types you want — Send Money, Payment, Cash In, Bank / Card Add Money. Each is enabled and confirmed separately; enabling one doesn't verify another.
  • Confirm payment detection (Bridge) is ready for that exact account — the Devzity Bridge app installed, signed in and healthy.
  • Add Service Credit for your business. It covers the small per-payment fee, and it belongs to the business (one balance for all your accounts).
  • Create a Live webhook endpoint and store its signing secret securely — it is different from your Test endpoint's. See Webhooks.
  • Use your production return_url (an https URL, or your app's production link).
  • Run one controlled live payment for a small amount and confirm the payment.paid webhook on your Live endpoint.

What live errors mean#

A live POST /v1/payments returns a 409 with one of these when something on the checklist is missing:

payment_detection_not_readyNo enabled, ready receiving method yet — configure an account and confirm detection.
payment_method_unavailableNone of your allowed_methods is usable right now.
payment_channel_unavailableNo ready receiving number accepts the requested allowed_channels.
insufficient_creditYour business's Service Credit can't cover the next fee — top it up in the dashboard.

Live payments use your business's Service Credit; if the balance is too low, live payment creation returns insufficient_credit. Test payments never use it.

I only have an app

Markdown

You have an Android, iOS, React Native or Flutter app and no server? You still must not put a Devzity secret key in it — you need one tiny endpoint.

Text
Your app  ──▶  your small backend  ──(Devzity secret key)──▶  Devzity
   ▲                  │
   └──── checkoutUrl ─┘

The rule that keeps you safe: the server decides the price#

The app sends an order id. The server loads that order and decides the amount itself. Never let the app send the amount to forward to Devzity — a tampered app could change ৳1,250 to ৳1.

Pick the stack you already know. Each example creates the payment for an order and returns only the checkout_url.

JavaScript
// The tiny trusted server a mobile app needs (Android, iOS, React Native, Flutter).
// The app calls THIS; only this server holds the Devzity secret key.
//
// The app sends an ORDER ID. The server looks up the order and decides the amount itself —
// never trust an amount sent by the app (it could be changed to ৳1).
import { createServer } from "node:http";
import { Devzity } from "@devzity/sdk";

const devzity = new Devzity({
  secretKey: process.env.DEVZITY_SECRET_KEY,
  baseUrl: process.env.DEVZITY_API_URL, // @preview-only — not needed once the SDK ships with the production URL built in
});

// Stand-in for YOUR database of orders.
const orders = new Map([["order-1001", { total: "1250.00", status: "AWAITING_PAYMENT", paymentId: null }]]);

const send = (res, status, body) => res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));

createServer(async (req, res) => {
  try {
    const pay = req.method === "POST" && req.url?.match(/^\/orders\/([\w-]+)\/pay$/);
    if (pay) {
      const order = orders.get(pay[1]);
      if (!order) return send(res, 404, { error: "order_not_found" });
      if (order.status === "PAID") return send(res, 409, { error: "already_paid" });

      const payment = await devzity.payments.create(
        {
          amount: order.total, // decided by the server, from the order
          externalId: pay[1],
          returnUrl: "myapp://payment-return", // your app's link
        },
        { idempotencyKey: `pay-${pay[1]}` }, // the same order never creates two payments
      );
      order.paymentId = payment.id;
      return send(res, 200, { checkout_url: payment.checkoutUrl });
    }

    const status = req.method === "GET" && req.url?.match(/^\/orders\/([\w-]+)\/status$/);
    if (status) {
      const order = orders.get(status[1]);
      if (!order) return send(res, 404, { error: "order_not_found" });
      // The authoritative answer: ask Devzity (a verified payment.paid webhook would update this too).
      if (order.paymentId) {
        const payment = await devzity.payments.retrieve(order.paymentId);
        if (payment.status === "PAID") order.status = "PAID";
      }
      return send(res, 200, { status: order.status });
    }
    send(res, 404, { error: "not_found" });
  } catch (error) {
    console.error(error);
    send(res, 500, { error: "server_error" });
  }
}).listen(Number(process.env.PORT ?? 3000));

The Node.js example also shows the second endpoint every app needs: GET /orders/:id/status, which answers from your records after asking Devzity. Deploy any of these where you like — a small server, a serverless function, or a Next.js route.

What the app does#

  1. Sends the order id to your backend and receives checkout_url.
  2. Opens it: DevzityCheckout.open(...) on Android or DevzityCheckout.present(...) on iOS; with the system browser on React Native and Flutter.
  3. Comes back through your return link, then asks your backend for the order's status.

Keep the order id in your app's saved state, so you can ask for the status even if the customer never returns through the link.

Android

Markdown

Open Devzity checkout from an Android app with Custom Tabs and handle the return link — without a secret key in your APK.

Your app never talks to Devzity's payments API. It talks to your small backend, and that backend talks to Devzity:

Text
Android app ──▶ your small backend ──(Devzity secret key)──▶ Devzity
     ▲                   │
     └──── checkoutUrl ──┘
Android app ──▶ DevzityCheckout.open(checkoutUrl) ──▶ customer pays ──▶ your return link ──▶ app
Android app ──▶ your backend ──▶ "is my order paid?"      (the authoritative answer)

The Devzity secret key stays on your backend. This SDK never stores or uses it. Don't have a backend yet? Read I only have an app.

1. Add the SDK#

The Android library is com.devzity.checkout (minSdk 21). It has one job: open the checkout page and read the return link.

2. Open checkout#

Ask your backend for a checkout URL for an order (it decides the amount), then open it:

Kotlin
DevzityCheckout.open(activity = this, checkoutUrl = checkoutUrl)

The SDK opens a Chrome Custom Tab (falling back to the default browser). It only accepts real https URLs and refuses javascript:, data:, file:, intent: and custom schemes. For local development only, DevzityCheckout.allowInsecureLocalhostForDebugging() also accepts http://localhost and the emulator host 10.0.2.2 — never call it in a release build.

Have your backend set the payment's return_url to your app's link. Register it in the manifest — a custom scheme is the simplest; an Android App Link (an https link you own, verified with assetlinks.json) is preferred once you have a domain:

AndroidManifest.xml · XML
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Only needed to call YOUR backend. Devzity's checkout opens in the browser. -->
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:label="Devzity Sample"
        android:allowBackup="false"
        android:theme="@android:style/Theme.DeviceDefault.Light">

        <!-- singleTask: the return link brings the existing Activity back to the
             front and delivers the link through onNewIntent. -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTask">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>

            <!-- Custom scheme: the simplest way to receive the return link.
                 Your backend sets  return_url = "devzitysample://payment-return". -->
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="devzitysample" android:host="payment-return" />
            </intent-filter>

            <!-- Android App Links (preferred once you own a domain): an https link
                 that opens your app directly, verified via assetlinks.json.
                 Uncomment and use your own domain.
            <intent-filter android:autoVerify="true">
                <action android:name="android.intent.action.VIEW" />
                <category android:name="android.intent.category.DEFAULT" />
                <category android:name="android.intent.category.BROWSABLE" />
                <data android:scheme="https" android:host="shop.example.com" android:pathPrefix="/payment-return" />
            </intent-filter>
            -->
        </activity>
    </application>
</manifest>

Then read it in the activity that handles the link:

Kotlin
val result = DevzityCheckout.parseReturn(intent.data)

when (result?.statusHint) {
    DevzityStatusHint.PAID -> {
        // Only a hint. Refresh the order from YOUR backend before showing anything as paid.
    }
    else -> Unit
}

parseReturn returns null for any link that isn't a Devzity return, so you can pass every incoming link to it. The result has paymentId, statusHint and returnUri.

Lifecycle: what closing checkout means#

The user can press back, close the browser, or your app can be killed while they pay. The SDK never treats any of that as a failed payment — it holds no payment state. Keep your order id (in saved instance state, or on your server), and when the app comes back — through the link or by the user reopening it — ask your backend for the order's status.

Sample app#

MainActivity.kt · Kotlin
package com.devzity.sample

import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import com.devzity.checkout.DevzityCheckout
import com.devzity.checkout.DevzityStatusHint
import kotlin.concurrent.thread

class MainActivity : Activity() {
    // Swap for HttpMerchantBackend("https://your-backend.example.com") to try a real payment.
    private val backend: MerchantBackend = FakeMerchantBackend()

    // The order being paid. Kept across process death: the user may be in the browser for a while.
    private var orderId = "order-1001"
    private lateinit var status: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        savedInstanceState?.getString("orderId")?.let { orderId = it }
        status = TextView(this).apply { text = "Tap Pay to start a test payment for $orderId." }
        val pay = Button(this).apply {
            text = "Pay for $orderId"
            setOnClickListener { startPayment() }
        }
        setContentView(LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(48, 48, 48, 48)
            addView(pay)
            addView(status)
        })
        handleReturn(intent) // the app may have been started by the return link
    }

    override fun onSaveInstanceState(outState: Bundle) {
        super.onSaveInstanceState(outState)
        outState.putString("orderId", orderId)
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        handleReturn(intent)
    }

    private fun startPayment() {
        thread {
            try {
                // 1. YOUR backend loads the order, decides the amount, creates the payment with its
                //    secret key and returns checkout_url. The app sends only the order id.
                val checkoutUrl = backend.createPaymentCheckoutUrl(orderId)
                // 2. The app only opens it.
                runOnUiThread { DevzityCheckout.open(activity = this, checkoutUrl = checkoutUrl) }
            } catch (e: Exception) {
                runOnUiThread { status.text = "Could not start payment: ${e.message}" }
            }
        }
    }

    private fun handleReturn(intent: Intent?) {
        val result = DevzityCheckout.parseReturn(intent?.data) ?: return
        // statusHint is only a HINT. Never fulfil anything from it: show a neutral message and
        // refresh the order from YOUR backend.
        status.text = when (result.statusHint) {
            DevzityStatusHint.CANCELLED -> "Checkout was cancelled. Checking your order…"
            DevzityStatusHint.PAID, DevzityStatusHint.UNKNOWN -> "Checking your payment…"
        }
        thread {
            val current = backend.orderStatus(orderId) // authoritative
            runOnUiThread { status.text = "Order status: $current" }
        }
    }
}

The sample contains no Devzity key. It uses a mock backend so it runs offline, and can point at a real one.

Open Devzity checkout from an iOS app with ASWebAuthenticationSession and handle the return — without a secret key in your app.

Your app never talks to Devzity's payments API. It talks to your small backend, and that backend talks to Devzity:

Text
iOS app ──▶ your small backend ──(Devzity secret key)──▶ Devzity
   ▲                  │
   └──── checkoutUrl ─┘
iOS app ──▶ DevzityCheckout.present(checkoutURL) ──▶ customer pays ──▶ myapp://… ──▶ app
iOS app ──▶ your backend ──▶ "is my order paid?"       (the authoritative answer)

The Devzity secret key stays on your backend. This SDK never stores or uses it. Don't have a backend yet? Read I only have an app.

1. Add the package#

DevzityCheckout is a Swift Package (iOS 15+, macOS 12+). It has one job: open the checkout page and read the return link.

2. Present checkout#

Swift
let result = try await DevzityCheckout.present(
    checkoutURL: checkoutURL,     // from YOUR backend, for an order
    callbackScheme: "myapp"       // your app's URL scheme
)

It uses ASWebAuthenticationSession, the system's secure browser sheet — never an in-app web view. The checkout URL must be https; javascript:, data:, file: and custom schemes are rejected with DevzityCheckoutError.invalidCheckoutURL. The callbackScheme is separate: the scheme your backend puts in the payment's return_url (for example myapp://payment-return).

result contains paymentID, statusHint and returnURL.

If the customer closes the sheet, present throws DevzityCheckoutError.closedByUser. That is not a failed payment — they may have already paid. Ask your backend for the order's status.

SwiftUI#

PayView.swift · Swift
import SwiftUI
import DevzityCheckout

/// 1. Your backend creates the payment for an ORDER.  2. The app opens checkout.  3. The customer
/// returns through `myapp://payment-return`.  4. The app asks YOUR backend for the order's real status.
public struct PayView: View {
    private let backend = MerchantBackend(baseURL: URL(string: "https://your-backend.example.com")!)
    private let orderID = "order-1001"   // the order being paid; the server decides its amount
    @StateObject private var checkout = DevzityCheckoutSession()
    @State private var message = "Ready"

    public init() {}

    public var body: some View {
        VStack(spacing: 16) {
            Button("Pay for \(orderID)") {
                Task {
                    do {
                        let url = try await backend.checkoutURL(forOrder: orderID)
                        checkout.start(checkoutURL: url, callbackScheme: "myapp")
                    } catch {
                        message = "Could not start payment"
                    }
                }
            }
            Text(message)
        }
        .onChange(of: checkout.state) { state in
            switch state {
            case .returned:
                // result.statusHint is only a hint — never fulfil or unlock from it.
                // Refresh the order from YOUR backend.
                message = "Checking your payment…"
                Task { message = "Order status: \((try? await backend.status(ofOrder: orderID)) ?? "unknown")" }
            case .closed:
                // The customer closed the sheet. That is NOT a failed payment: ask your backend.
                message = "Checkout closed. Checking your order…"
                Task { message = "Order status: \((try? await backend.status(ofOrder: orderID)) ?? "unknown")" }
            case .failed:
                message = "Could not open checkout"
            case .idle, .presenting:
                break
            }
        }
    }
}

DevzityCheckoutSession is a tiny ObservableObject (states idle, presenting, returned, closed, failed). UIKit apps call DevzityCheckout.present from a Task and may pass their own presentationAnchor.

MerchantBackend.swift · Swift
import Foundation

/// The app never talks to Devzity's payments API and never holds a secret key. It asks YOUR backend
/// about an ORDER: the backend decides the amount from its own order, creates the payment with its
/// secret key, and returns only the checkout_url. The app never sends an amount.
struct MerchantBackend {
    let baseURL: URL

    func checkoutURL(forOrder orderID: String) async throws -> String {
        var request = URLRequest(url: baseURL.appendingPathComponent("orders/\(orderID)/pay"))
        request.httpMethod = "POST"
        let (data, _) = try await URLSession.shared.data(for: request)
        return try JSONDecoder().decode([String: String].self, from: data)["checkout_url"] ?? ""
    }

    /// The authoritative state of the order, from YOUR backend (which asks Devzity / receives its webhook).
    func status(ofOrder orderID: String) async throws -> String {
        let url = baseURL.appendingPathComponent("orders/\(orderID)/status")
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([String: String].self, from: data)["status"] ?? "unknown"
    }
}

Custom URL schemes (myapp://) can be claimed by any other app. Where practical, use a Universal Link — an https link on a domain you own, verified with apple-app-site-association — as your return_url and call the iOS 17.4+ overload:

Swift
try await DevzityCheckout.present(
    checkoutURL: checkoutURL,
    universalLinkHost: "shop.example.com",
    path: "/payment-return"
)

Custom schemes remain fully supported and are the simplest way to start. If the app receives a link some other way (onOpenURL), DevzityCheckout.parseReturn(url) reads the same hints and returns nil for links that aren't Devzity returns.

React Native

Markdown

Accept Devzity payments in a React Native app using your own backend, the system browser and a deep link.

No native wrapper package is published for React Native yet — but the integration is fully supported today. It uses the same secure pattern as Android and iOS, with the built-in Linking API, the checkout_url your backend creates, the system browser, and a deep link.

Text
React Native app ──▶ your small backend ──(Devzity secret key)──▶ Devzity
        ▲                    │
        └──── checkout_url ──┘
app opens checkout_url ──▶ customer pays ──▶ myapp://payment-return?… ──▶ app
app ──▶ your backend ──▶ "is my order paid?"

The Devzity secret key stays on your backend. Never put it in a React Native bundle — the JavaScript ships inside the app and can be read by anyone. No backend yet? See I only have an app.

The flow#

  1. The app sends an order id to your backend; the backend decides the amount and returns checkout_url.
  2. The app opens it with Linking.openURL.
  3. Your backend set the payment's return_url to your app's deep link (myapp://payment-return). Configure that scheme in your app (scheme in Expo's app.json, or native URL types).
  4. When the app receives the link, ask your backend for the order's status.
PayScreen.tsx · TypeScript
// React Native: the app never holds a Devzity secret key. It sends an ORDER ID to YOUR backend (the
// server decides the amount — see examples/mobile-backend), opens the checkout_url it returns, and
// asks YOUR backend for the order's real status when the customer comes back.
import React, { useEffect, useRef, useState } from "react";
import { Button, Linking, Text, View } from "react-native";

const BACKEND = "https://your-backend.example.com";
const ORDER_ID = "order-1001"; // the order being paid

export default function PayScreen() {
  const [message, setMessage] = useState("Ready");
  const orderId = useRef(ORDER_ID); // keep it so the status check works even if the link never arrives

  async function refreshOrder() {
    // The authoritative answer — from YOUR backend, never from the return link.
    const { status } = await (await fetch(`${BACKEND}/orders/${orderId.current}/status`)).json();
    setMessage(`Order status: ${status}`);
  }

  useEffect(() => {
    // Return link: myapp://payment-return?devzity_payment_id=…&devzity_status=PAID
    const subscription = Linking.addEventListener("url", ({ url }) => {
      if (!url.includes("devzity_status")) return;
      // devzity_status is only a HINT — never fulfil from it. Refresh the order instead.
      setMessage("Checking your payment…");
      void refreshOrder();
    });
    return () => subscription.remove();
  }, []);

  async function pay() {
    const response = await fetch(`${BACKEND}/orders/${orderId.current}/pay`, { method: "POST" });
    const { checkout_url } = await response.json();
    await Linking.openURL(checkout_url); // opens the system browser
  }

  return (
    <View>
      <Button title={`Pay for ${ORDER_ID}`} onPress={pay} />
      <Text>{message}</Text>
    </View>
  );
}

Closing the browser or backgrounding the app is not a failed payment. Keep the order id (state or storage) so you can ask your backend for the status even if the deep link never arrives.

Flutter

Markdown

Accept Devzity payments in a Flutter app using your own backend, url_launcher and a deep link.

No Flutter plugin is published for Devzity yet — but the integration is fully supported today. It uses the same secure pattern as Android and iOS, with standard packages, the checkout_url your backend creates, the system browser, and a deep link.

Text
Flutter app ──▶ your small backend ──(Devzity secret key)──▶ Devzity
     ▲                  │
     └─ checkout_url ───┘
launchUrl(checkout_url) ──▶ customer pays ──▶ myapp://payment-return?… ──▶ app
app ──▶ your backend ──▶ "is my order paid?"

The Devzity secret key stays on your backend. Never put it in a Flutter or Dart bundle — a compiled app can be decompiled. No backend yet? See I only have an app.

The flow#

  1. The app sends an order id to your backend; the backend decides the amount and returns checkout_url.
  2. Open it with url_launcher (LaunchMode.externalApplication).
  3. Your backend set the payment's return_url to your app's deep link. Register it as an Android intent filter / App Link and an iOS URL scheme / Universal Link, and listen with app_links.
  4. When the link arrives, ask your backend for the order's status.
pay_button.dart · Dart
// Flutter: the app never holds a Devzity secret key. It sends an ORDER ID to YOUR backend (the server
// decides the amount — see examples/mobile-backend), opens the checkout_url it returns with
// url_launcher, and asks YOUR backend for the order's real status when the customer returns through
// your deep link (app_links).
//
// pubspec.yaml: url_launcher, http, app_links
import 'dart:convert';

import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';

const backend = 'https://your-backend.example.com';
const orderId = 'order-1001'; // the order being paid

class PayButton extends StatefulWidget {
  const PayButton({super.key});

  @override
  State<PayButton> createState() => _PayButtonState();
}

class _PayButtonState extends State<PayButton> {
  String message = 'Ready';

  @override
  void initState() {
    super.initState();
    // Return link: myapp://payment-return?devzity_payment_id=…&devzity_status=PAID
    AppLinks().uriLinkStream.listen((uri) async {
      if (!uri.queryParameters.containsKey('devzity_status')) return;
      // devzity_status is only a HINT — never fulfil from it. Refresh the order from YOUR backend.
      setState(() => message = 'Checking your payment…');
      final response = await http.get(Uri.parse('$backend/orders/$orderId/status'));
      setState(() => message = 'Order status: ${jsonDecode(response.body)['status']}');
    });
  }

  Future<void> pay() async {
    final response = await http.post(Uri.parse('$backend/orders/$orderId/pay'));
    final checkoutUrl = jsonDecode(response.body)['checkout_url'] as String;
    await launchUrl(Uri.parse(checkoutUrl), mode: LaunchMode.externalApplication);
  }

  @override
  Widget build(BuildContext context) => Column(children: [
        ElevatedButton(onPressed: pay, child: const Text('Pay for $orderId')),
        Text(message),
      ]);
}

REST & other languages

Markdown

Create payments over plain HTTP from PHP, Laravel, Java / Spring Boot, Go, Python, Node.js without an SDK, or curl.

The API is plain JSON over HTTPS. You don't need an SDK. Every example below creates a test payment; run them on your server with:

Shell
export DEVZITY_SECRET_KEY=dz_test_sk_...
export DEVZITY_API_URL=https://api.devzity.com
  • Headers: Authorization: Bearer <secret key>, Content-Type: application/json, and an Idempotency-Key (see Idempotency).
  • Amounts are strings: "500.00".

Pick your language:

curl#

create-payment.sh · Shell
#!/usr/bin/env bash
# Create a TEST payment with plain curl. Needs a Test secret key from
# Developers → API keys (it starts with dz_test_sk_). No real money is involved.
set -euo pipefail
: "${DEVZITY_SECRET_KEY:?Set DEVZITY_SECRET_KEY to your dz_test_sk_... key}"
: "${DEVZITY_API_URL:?Set DEVZITY_API_URL to the Devzity API base URL you were given}"

curl "$DEVZITY_API_URL/v1/payments" \
  -X POST \
  -H "Authorization: Bearer $DEVZITY_SECRET_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"amount":"500.00"}'

Node.js (no SDK)#

create-payment.mjs · JavaScript
// Plain Node.js (18+), no dependencies: create a TEST payment with fetch.
// Run on your SERVER — the secret key must never reach a browser or an app.
import { randomUUID } from "node:crypto";

const apiUrl = process.env.DEVZITY_API_URL; // the Devzity API base URL you were given
if (!apiUrl || !process.env.DEVZITY_SECRET_KEY) throw new Error("Set DEVZITY_API_URL and DEVZITY_SECRET_KEY");

const response = await fetch(`${apiUrl}/v1/payments`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.DEVZITY_SECRET_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID(),
  },
  body: JSON.stringify({ amount: "500.00" }),
});

const payment = await response.json();
if (!response.ok) {
  // Every error has a code, a message and a request_id to quote to support.
  throw new Error(`${payment.error.code}: ${payment.error.message} (${payment.error.request_id})`);
}
console.log(payment.checkout_url);

PHP#

create-payment.php · PHP
<?php
// Plain PHP (cURL extension) — run on your SERVER. Test mode: use a dz_test_sk_ key.
$secretKey = getenv('DEVZITY_SECRET_KEY');
$apiUrl = getenv('DEVZITY_API_URL'); // the Devzity API base URL you were given
if (!$secretKey || !$apiUrl) {
    throw new RuntimeException('Set DEVZITY_SECRET_KEY and DEVZITY_API_URL');
}

$ch = curl_init("$apiUrl/v1/payments");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $secretKey",
        'Content-Type: application/json',
        'Idempotency-Key: ' . bin2hex(random_bytes(16)),
    ],
    CURLOPT_POSTFIELDS => json_encode(['amount' => '500.00']),
]);

$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

$payment = json_decode($body, true);
if ($status >= 400) {
    // Every error has a code, a message and a request_id to quote to support.
    throw new RuntimeException("{$payment['error']['code']}: {$payment['error']['message']} ({$payment['error']['request_id']})");
}

header('Location: ' . $payment['checkout_url']); // or echo it / return it to your app

Laravel#

DevzityController.php · PHP
<?php
// app/Http/Controllers/DevzityController.php (Laravel 10+)
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class DevzityController extends Controller
{
    // POST /pay — creates a TEST payment and sends the customer to checkout.
    public function pay()
    {
        $response = Http::withToken(config('services.devzity.secret'))
            ->withHeaders(['Idempotency-Key' => (string) Str::uuid()])
            ->post(config('services.devzity.url') . '/v1/payments', ['amount' => '500.00'])
            ->throw();

        return redirect()->away($response->json('checkout_url'));
    }

    // POST /devzity/webhook — exclude this route from CSRF verification.
    public function webhook(Request $request)
    {
        $rawBody = $request->getContent();                          // the RAW body
        $header = (string) $request->header('Devzity-Signature');
        parse_str(str_replace(',', '&', $header), $parts);
        $timestamp = $parts['t'] ?? '';
        $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, config('services.devzity.webhook_secret'));

        abort_unless(
            ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300 && hash_equals($expected, $parts['v1'] ?? ''),
            400
        );

        $event = json_decode($rawBody, true);
        if ($event['type'] === 'payment.paid') {
            // Fulfil $event['data']['object']['id']. Deduplicate on $event['id'].
        }

        return response()->json(['received' => true]);
    }
}

Setup:

setup.md · Text
# Laravel setup

`config/services.php`:

```php
'devzity' => [
    'url' => env('DEVZITY_API_URL'),
    'secret' => env('DEVZITY_SECRET_KEY'),
    'webhook_secret' => env('DEVZITY_WEBHOOK_SECRET'),
],
```

`.env` (server-side only): `DEVZITY_SECRET_KEY=dz_test_sk_...` and `DEVZITY_API_URL=<the Devzity API base URL you were given>`

`routes/web.php`:

```php
Route::post('/pay', [DevzityController::class, 'pay']);
Route::post('/devzity/webhook', [DevzityController::class, 'webhook']);
```

Exclude `devzity/webhook` from CSRF in `bootstrap/app.php`:
`$middleware->validateCsrfTokens(except: ['devzity/webhook']);`

Java / Spring Boot#

DevzityService.java · Java
package com.example.devzity;

import java.util.Map;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

/**
 * Creates Devzity payments from your Spring Boot SERVER. The secret key comes from
 * configuration (devzity.secret-key / DEVZITY_SECRET_KEY) — never hard-code it.
 */
@Service
public class DevzityService {

    private final RestClient client;

    public DevzityService(
            @Value("${devzity.api-url}") String apiUrl,
            @Value("${devzity.secret-key}") String secretKey) {
        this.client = RestClient.builder()
                .baseUrl(apiUrl)
                .defaultHeader("Authorization", "Bearer " + secretKey)
                .build();
    }

    /** Returns the checkout_url to send the customer (or your mobile app) to. */
    @SuppressWarnings("unchecked")
    public String createPayment(String amount) {
        Map<String, Object> payment = client.post()
                .uri("/v1/payments")
                .header("Idempotency-Key", UUID.randomUUID().toString())
                .body(Map.of("amount", amount))
                .retrieve()
                .body(Map.class);
        return (String) payment.get("checkout_url");
    }
}
application.properties · Properties
# Supply these as environment variables (DEVZITY_API_URL, DEVZITY_SECRET_KEY, DEVZITY_WEBHOOK_SECRET) — never commit them.
devzity.api-url=${DEVZITY_API_URL}
devzity.secret-key=${DEVZITY_SECRET_KEY}
devzity.webhook-secret=${DEVZITY_WEBHOOK_SECRET:}

Supply DEVZITY_API_URL, DEVZITY_SECRET_KEY and DEVZITY_WEBHOOK_SECRET as environment variables; never commit them.

Go#

create_payment.go · Go
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

// createPayment creates a TEST payment with plain net/http and returns its checkout URL.
// Run it on your SERVER; the secret key must never reach a browser or an app.
func createPayment(amount string) (string, error) {
	apiURL := os.Getenv("DEVZITY_API_URL") // the Devzity API base URL you were given
	if apiURL == "" || os.Getenv("DEVZITY_SECRET_KEY") == "" {
		return "", fmt.Errorf("set DEVZITY_API_URL and DEVZITY_SECRET_KEY")
	}

	body, _ := json.Marshal(map[string]string{"amount": amount})
	req, err := http.NewRequest(http.MethodPost, apiURL+"/v1/payments", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("DEVZITY_SECRET_KEY"))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", newIdempotencyKey())

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	var payload struct {
		CheckoutURL string `json:"checkout_url"`
		Error       struct {
			Code      string `json:"code"`
			Message   string `json:"message"`
			RequestID string `json:"request_id"`
		} `json:"error"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return "", err
	}
	if resp.StatusCode >= 400 {
		// Every error has a code, a message and a request_id to quote to support.
		return "", fmt.Errorf("%s: %s (%s)", payload.Error.Code, payload.Error.Message, payload.Error.RequestID)
	}
	return payload.CheckoutURL, nil
}

func newIdempotencyKey() string {
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	return fmt.Sprintf("%x", b)
}

Python#

create_payment.py · Python
"""Create a TEST payment with only the Python standard library (3.8+).
Run on your SERVER; the secret key must never reach a browser or an app."""
import json
import os
import urllib.error
import urllib.request
import uuid

api_url = os.environ["DEVZITY_API_URL"]  # the Devzity API base URL you were given

request = urllib.request.Request(
    f"{api_url}/v1/payments",
    data=json.dumps({"amount": "500.00"}).encode(),
    method="POST",
    headers={
        "Authorization": f"Bearer {os.environ['DEVZITY_SECRET_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": str(uuid.uuid4()),
    },
)

try:
    with urllib.request.urlopen(request) as response:
        payment = json.load(response)
except urllib.error.HTTPError as error:
    # Every error has a code, a message and a request_id to quote to support.
    detail = json.load(error)["error"]
    raise SystemExit(f"{detail['code']}: {detail['message']} ({detail['request_id']})")

print(payment["checkout_url"])

The response is the Payment object; send the customer to checkout_url.

Webhooks#

Every language needs the same steps: read the raw body, verify Devzity-Signature, deduplicate on event.id, respond 2xx. Ready-to-copy verifiers for Node.js, PHP, Java, Go and Python are on the Webhooks page.

Errors#

A failed request returns a JSON error with a code, a message, an optional param, and a request_id:

JSON
{ "error": { "code": "invalid_amount", "message": "Enter a valid amount, e.g. 500.00.", "param": "amount", "request_id": "req_…" } }

See Errors and Troubleshooting. The complete request and response reference is the API reference; the machine-readable source is openapi/devzity-v1.yaml.

Errors

Markdown

The error format, every error code, and what to send support.

Errors use standard HTTP status codes and one JSON shape:

JSON
{
  "error": {
    "code": "external_id_conflict",
    "message": "A payment with this external_id already exists for this business.",
    "param": "external_id",
    "request_id": "req_1df6dd76-0350-479c-a234-e16d63b0f78b"
  }
}
  • code is stable and machine-readable — switch on it. New codes can be added, so keep a default branch.
  • message is for humans and may change.
  • param names the offending field when there is one.
  • request_id — include it when you contact support. It lets us find the exact request.

The SDK raises typed errors (DevzityValidationError, DevzityAuthenticationError, DevzityRateLimitError, DevzityIdempotencyError, DevzityAPIError, DevzityNetworkError), each with code, message, param, requestId and httpStatus.

Handling an error#

JavaScript
import { DevzityAPIError } from "@devzity/sdk"

try {
  await devzity.payments.create({ amount: 500 })
} catch (error) {
  if (error instanceof DevzityAPIError) {
    console.error(error.code, error.requestId)
  }
}

Not sure what a code means? See Troubleshooting.

Error codes#

CodeHTTPMeaning
invalid_api_key401The API key is missing, malformed, unknown, or revoked.
insufficient_scope403The key lacks the scope (payments:read or payments:write) this endpoint needs.
subscription_expired402The business subscription has expired; renew it before using the API key again.
rate_limit_exceeded429Too many requests for this key. Honor the Retry-After header.
invalid_request400The request body or a parameter is malformed.
invalid_amount400amount must be a positive decimal string with at most two decimals, e.g. "500.00".
invalid_external_id400external_id is invalid or too long.
invalid_metadata400metadata exceeds its limits (20 keys, 64-char keys, 500-char values).
invalid_return_url400return_url must be an absolute https URL or a native app deep link.
invalid_allowed_method400allowed_methods must only name BKASH or NAGAD.
invalid_allowed_channel400allowed_channels must only name SEND_MONEY, PAYMENT, CASH_IN or BANK_TRANSFER.
description_too_long400description is too long.
reference_too_long400merchant_reference is too long.
invalid_customer400customer_id does not belong to your business.
order_not_found400order_id does not exist for your business.
order_customer_mismatch400The payment's customer does not match the order's customer.
exceeds_order_due400amount exceeds the order's remaining due.
context_items_not_accepted400context_items are not accepted by the Developer API.
payment_not_found404No hosted payment with this id exists for this key's business and environment.
not_found404No such API route.
method_not_allowed405The HTTP method is not allowed for this route.
external_id_conflict409A payment with this external_id already exists (per business and environment).
idempotency_conflict409This Idempotency-Key was already used with a different request.
idempotency_in_progress409A request with this Idempotency-Key is still being processed; retry shortly.
invalid_transition409The payment is not in a state that allows this action (for example, cancelling a paid payment).
order_cancelled409The referenced order is cancelled.
payment_method_unavailable409None of the allowed payment methods are currently usable (live mode).
payment_channel_unavailable409No ready receiving number accepts the requested payment type (live mode).
payment_detection_not_ready409The business has no enabled, ready receiving method yet (live mode).
insufficient_credit409The business does not have enough Service Credit for the next fee (live mode).
internal_error500Something went wrong on Devzity's side. Retry; quote request_id to support if it persists.

Retrying#

Retry only what is safe. 429 (rate limit — wait for Retry-After) and 5xx are retryable; other 4xx errors are not — fix the request. Always retry a create with the same Idempotency-Key (the SDK does this for you). See Idempotency.

Troubleshooting

Markdown

What to do when a request fails — start with the request_id.

First, copy the request_id. Every error response has one:

JSON
{ "error": { "code": "invalid_api_key", "message": "…", "request_id": "req_1df6dd76-…" } }

With the SDK it is error.requestId. Include it whenever you contact Devzity support — it lets us find the exact request.

Common problems#

invalid_api_key (401)The key is missing, mistyped, revoked, or you're sending a test key to a live setup (or the reverse).Check DEVZITY_SECRET_KEY, and that it is the key you created for this environment. Create or rotate a key under Developers → API keys.
payment_detection_not_ready (409)Live mode only. The business has no enabled, ready receiving method yet.Follow Going live. Test mode never needs this.
insufficient_credit (409)Live mode only. The business's Service Credit can't cover the next fee.Top up Service Credit in the dashboard.
idempotency_conflict (409)The same Idempotency-Key was used with a different request body.Use a new key for a genuinely different payment; reuse a key only for a retry of the same request.
rate_limit_exceeded (429)Too many requests for this key.Wait for the Retry-After header. The SDK does this automatically.
Webhook returns 400 / signature failsYou verified a re-serialized or already-parsed body, or used the wrong secret (test vs live endpoint).Verify the raw body, and use the signing secret of that exact endpoint. See Webhooks.
payment.paid never arrivesWrong environment endpoint, or your server didn't answer 2xx.A Test payment only reaches a Test endpoint. Check the delivery log under Developers → Webhooks.

The full list is on the Errors page.

Nothing works and the message isn't helpful#

Check, in order: you are using a test key against test mode; DEVZITY_API_URL is the API base URL you were given; the request has Authorization: Bearer <key>; the JSON body is valid. Then send us the request_id.

Idempotency

Markdown

Safely retry payment creation without ever creating a duplicate payment.

Networks fail. If your request times out you can't tell whether the payment was created. An idempotency key makes retrying safe: the same key with the same request returns the original result instead of creating a second payment.

What the SDK retries#

Only when it is safe. A create is retried after a network failure, a 429 (waiting for Retry-After) or a 502/503/504, always with the same idempotency key, so a retry can never create a second payment. Reads are retried the same way. A cancel is retried only after a 429. Ordinary 4xx errors are never retried.

REST#

Send an Idempotency-Key header on POST /v1/payments:

Shell
curl https://api.devzity.com/v1/payments \
  -X POST \
  -H "Authorization: Bearer $DEVZITY_SECRET_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"amount":"500.00"}'
  • Use a random value (a UUID) per logical payment, and reuse it for every retry of that payment.
  • Same key + same body → the original response is returned; no new payment.
  • Same key + different body → 409 idempotency_conflict.
  • A request with that key still being processed → 409 idempotency_in_progress; retry shortly.
  • Keys are remembered for 24 hours and are scoped to the API key.

Idempotency keys protect retries of the same request. To prevent two different requests for the same order, also set external_id: a second payment with the same external_id returns external_id_conflict.

Versioning and stability

Markdown

What v1 promises, what changes in SDK 0.x, and how deprecations will work.

The API: /v1#

/v1 is the current public API version. Within v1 we only make additive changes: new endpoints, new optional request fields, new response fields, and new values for values the API returns (such as a new payment status or event type). A breaking change — removing or renaming a field, endpoint or value, making an optional request field required, or changing a type — requires a new major API version (/v2) or an explicit, announced deprecation process. We do not promise a support timeline for old versions yet.

Because v1 can gain new values, write clients that tolerate them: keep a default branch when you switch on a status, channel, method or event type. The official SDKs already do this — an unknown value is passed through instead of crashing. Requests stay strict: sending a value the API doesn't know is rejected.

The SDKs: 0.x#

SDK packages follow their own semantic versioning and are 0.x: the package's API may still change between minor versions while we learn from real use. That is separate from the REST contract: an SDK release never changes what /v1 means, and /v1 compatibility does not depend on which SDK version you use. Pin an exact SDK version and read the changelog when you upgrade.

The docs#

/v1 documents API v1, whichever SDK version you use.

Deprecations#

Nothing is deprecated today. When something is, we will: mark it deprecated in the API reference and changelog, add a notice in these docs, send a Deprecation response header where useful, and only announce a sunset date once a real timeline exists. We won't invent dates.

SDK reference

Markdown

The @devzity/sdk API, generated from its TypeScript source.

@devzity/sdk is the server-side Node.js (18+) / TypeScript SDK. It holds your secret key, so it must never be bundled for a browser or mobile app — importing it in a browser build fails on purpose. Next.js apps can use the thin @devzity/next wrapper (createDevzity() reads DEVZITY_SECRET_KEY; verifyDevzityWebhook(request) verifies a Route Handler webhook using DEVZITY_WEBHOOK_SECRET).

TypeScript
import { Devzity } from "@devzity/sdk"

const devzity = new Devzity({ secretKey: process.env.DEVZITY_SECRET_KEY! })

Amounts: amount: 500 is fine for simple values; for exact money pass a string ("500.50"). Anything that can't be represented exactly with two decimals is rejected, not rounded.

Forward compatibility: responses may contain a payment status, method or channel newer than your SDK — they are passed through as strings, so keep a default branch. A webhook event type newer than your SDK is verified and returned as type: "unknown" with rawType and the raw data.object, so you can log and ignore it. Request-side values stay strict.

baseUrl: a preview SDK build has no built-in API URL — pass baseUrl: process.env.DEVZITY_API_URL (a release ships with it locked, and then it is optional).

Defaults: 30 s request timeout, up to 2 automatic retries (network errors, 429 honoring Retry-After, 502/503/504) that always reuse the same idempotency key. The environment is inferred from the key prefix.

Generated from the TypeScript source of @devzity/sdk 0.1.0.

Classes

Devzity

The Devzity server client. SERVER-SIDE ONLY: it holds your secret key, so never bundle it into browser JavaScript, an Android/iOS app, React Native or Flutter. Mobile and browser apps get a `checkoutUrl` from YOUR backend.

new Devzity(options: DevzityOptions)
environment : "live" | "test"
Inferred from the key prefix; the server remains authoritative.
payments : Payments
testHelpers : TestHelpers
Sandbox helpers (`dz_test_sk_` keys only).
webhooks : { readonly verify: (rawBody: RawBody, signatureHeader: string | null | undefined, secret: string, options?: VerifyOptions) => WebhookEvent; readonly computeSignature: (secret: string, timestamp: number, rawBody: RawBody) => string; readonly DEFAULT_TOLERANCE_SECONDS: 300; }

DevzityAPIError

The API answered with an error. Quote `requestId` to Devzity support. Narrow with the subclasses below, or switch on `code` (an open string — new codes can be added without a major SDK release).

new DevzityAPIError(fields: APIErrorFields)
cause ?: unknown
code : string
httpStatus : number
message : string
name : string
param : string | undefined
requestId : string | undefined
stack ?: string | undefined

DevzityAuthenticationError

401 / 403: the key is invalid, revoked, or lacks the needed scope.

new DevzityAuthenticationError(fields: APIErrorFields)
cause ?: unknown
code : string
httpStatus : number
message : string
name : string
param : string | undefined
requestId : string | undefined
stack ?: string | undefined

DevzityConfigError

The SDK was configured incorrectly (bad/missing key, unsafe environment). Never sent to the API.

new DevzityConfigError(message: string, options?: { cause?: unknown; })
cause ?: unknown
message : string
name : string
stack ?: string | undefined

DevzityError

Base class for every error this SDK throws.

new DevzityError(message: string, options?: { cause?: unknown; })
cause ?: unknown
message : string
name : string
stack ?: string | undefined

DevzityIdempotencyError

409 idempotency_conflict / idempotency_in_progress.

new DevzityIdempotencyError(fields: APIErrorFields)
cause ?: unknown
code : string
httpStatus : number
message : string
name : string
param : string | undefined
requestId : string | undefined
stack ?: string | undefined

DevzityInputError

Invalid input rejected locally, before any request is made (for example an unusable `amount`).

new DevzityInputError(message: string, param: string)
cause ?: unknown
message : string
name : string
param : string
stack ?: string | undefined

DevzityNetworkError

No response was received: DNS/connection failure, timeout, or the request was aborted.

new DevzityNetworkError(message: string, options?: { cause?: unknown; timedOut?: boolean; })
cause ?: unknown
message : string
name : string
stack ?: string | undefined
timedOut : boolean

DevzityRateLimitError

429: too many requests. `retryAfterSeconds` mirrors the Retry-After header when present.

new DevzityRateLimitError(fields: APIErrorFields & { retryAfterSeconds?: number; })
cause ?: unknown
code : string
httpStatus : number
message : string
name : string
param : string | undefined
requestId : string | undefined
retryAfterSeconds : number | undefined
stack ?: string | undefined

DevzitySignatureVerificationError

A webhook could not be verified. Never trust or process the payload.

new DevzitySignatureVerificationError(message: string, reason: DevzitySignatureVerificationError["reason"])
cause ?: unknown
message : string
name : string
reason : "malformed_header" | "timestamp_outside_tolerance" | "no_matching_signature"
stack ?: string | undefined

DevzityValidationError

400: a parameter was rejected. `param` names it when known.

new DevzityValidationError(fields: APIErrorFields)
cause ?: unknown
code : string
httpStatus : number
message : string
name : string
param : string | undefined
requestId : string | undefined
stack ?: string | undefined

Payments

new Payments(http: HttpClient)
cancel (id: string, options?: RequestOptions): Promise<Payment>
Cancels a PENDING payment. Only a 429 is retried automatically (a cancel is not idempotency-keyed).
create (params: PaymentCreateParams, options?: RequestOptions): Promise<Payment>
Creates a hosted Payment and returns it with its `checkoutUrl`. A secure Idempotency-Key is generated automatically (and reused on every automatic retry), so a retried call can never create a second payment. Pass `{ idempotencyKey }` to use your own, e.g. your order id.
list (params?: PaymentListParams, options?: RequestOptions): Promise<PaymentList>
One page. Use `listAutoPaging` to iterate every payment.
listAutoPaging (params?: Omit<PaymentListParams, "startingAfter">, options?: RequestOptions): AsyncGenerator<Payment, void, undefined>
Iterates every matching payment, fetching pages lazily. Optional — `list` is always available.
retrieve (id: string, options?: RequestOptions): Promise<Payment>
Retrieves one payment by id.

TestHelpers

Sandbox-only helpers. They work with a `dz_test_sk_` key; a live key gets the API's normal 404. They exist to complete a Test payment without any real money, SMS or bKash/Nagad account.

new TestHelpers(http: HttpClient)
payments : { succeed: (id: string, options?: RequestOptions) => Promise<Payment>; cancel: (id: string, options?: RequestOptions) => Promise<Payment>; }

Functions

normalizeAmount

Normalizes a developer-friendly amount to the API's canonical decimal string ("500" -> "500.00"). No float arithmetic and no rounding: anything that can't be represented exactly with two decimals is REJECTED rather than silently changed — 0.001 throws, it never becomes "0.00".

(amount: number | string): string

verifyWebhook

Verifies a webhook and returns the typed event. Pass the RAW request body — the exact bytes received, before any JSON parsing — and the value of the `Devzity-Signature` header. Throws DevzitySignatureVerificationError if it cannot be trusted; never process a payload that failed verification.

(rawBody: RawBody, signatureHeader: string | null | undefined, secret: string, options?: VerifyOptions): WebhookEvent

Constants

DEFAULT_TOLERANCE_SECONDS

Reject signatures older (or further in the future) than this.

300

SDK_VERSION

"0.1.0"

webhooks

`devzity.webhooks` — also importable standalone, since a webhook receiver needs only the signing secret, not an API key.

verify (rawBody: RawBody, signatureHeader: string | null | undefined, secret: string, options?: VerifyOptions): WebhookEvent
computeSignature (secret: string, timestamp: number, rawBody: RawBody): string
DEFAULT_TOLERANCE_SECONDS : 300

Types

DevzityOptions

secretKey : string
Your secret key: `dz_test_sk_…` (sandbox) or `dz_live_sk_…`. Server-side only.
baseUrl ?: string | undefined
The Devzity API base URL, for example `process.env.DEVZITY_API_URL`. Required until a release ships with the production URL built in; then it is optional.
timeoutMs ?: number | undefined
Default 30000.
maxRetries ?: number | undefined
Automatic retries for safe failures. Default 2.
fetch ?: { (input: RequestInfo | URL, init?: RequestInit): Promise<Response>; (input: string | URL | Request, init?: RequestInit): Promise<Response>; } | undefined
A fetch implementation; defaults to the runtime's global `fetch`.
retryBaseDelayMs ?: number | undefined
Advanced/testing: first backoff delay in ms (default 500).

EndpointTestEvent

The synthetic, non-financial event sent from the dashboard's "Send test event". It contains NO Payment.

type : "endpoint.test"
data : { object: { message: string; endpointId: string | null; }; }
id : string
livemode : boolean
createdAt : Date
apiVersion : string

Payment

A hosted Payment. `amount` is the canonical decimal string, e.g. "500.00".

id : string
livemode : boolean
status : PaymentStatus
amount : string
currency : "BDT"
description : string | null
merchantReference : string | null
externalId : string | null
metadata : Record<string, string>
allowedMethods : PaymentMethod[]
allowedChannels : CollectionChannel[]
returnUrl : string | null
checkoutUrl : string | null
Send your customer here (or open it from a native app).
createdAt : Date
paidAt : Date | null
cancelledAt : Date | null

PaymentCreateParams

amount : string | number
500, 500.5 or "500.50". Sent as a canonical two-decimal string.
description ?: string | undefined
merchantReference ?: string | undefined
externalId ?: string | undefined
returnUrl ?: string | undefined
metadata ?: Record<string, string> | undefined
allowedMethods ?: ("BKASH" | "NAGAD")[] | undefined
allowedChannels ?: ("SEND_MONEY" | "PAYMENT" | "CASH_IN" | "BANK_TRANSFER")[] | undefined
customerId ?: string | undefined
orderId ?: string | undefined

PaymentEvent

A `payment.*` event. `data.object` is the same Payment `payments.retrieve` returns.

type : T
data : { object: Payment; }
id : string
livemode : boolean
createdAt : Date
apiVersion : string

PaymentList

data : Payment[]
hasMore : boolean
nextCursor : string | null

PaymentListParams

status ?: "PENDING" | "PAID" | "CANCELLED" | "REVERSED" | undefined
externalId ?: string | undefined
createdFrom ?: string | Date | undefined
createdTo ?: string | Date | undefined
limit ?: number | undefined
1–100, default 20.
startingAfter ?: string | undefined
A payment id: return payments after it (the previous page's `nextCursor`).

RequestOptions

idempotencyKey ?: string | undefined
Override the automatically generated Idempotency-Key (payments.create only).
timeoutMs ?: number | undefined
Per-request timeout in milliseconds.
maxRetries ?: number | undefined
Per-request retry budget.
signal ?: AbortSignal | undefined

UnknownWebhookEvent

An event type newer than this SDK. Its signature IS verified and it is safe to log or ignore: `rawType` is the type the API sent, `data.object` the raw payload. (`type` is the literal "unknown" so narrowing on the known event types stays exact.)

type : "unknown"
rawType : string
data : { object: Record<string, unknown>; }
id : string
livemode : boolean
createdAt : Date
apiVersion : string

Type aliases

CollectionChannel

KnownCollectionChannel | (string & {})

KnownCollectionChannel

How the customer pays: SEND_MONEY ("Send Money"), PAYMENT ("Payment"), CASH_IN ("Cash In"), BANK_TRANSFER ("Bank / Card Add Money").

Wire["CollectionChannel"]

KnownPaymentMethod

Wire["PaymentMethod"]

KnownPaymentStatus

Statuses this SDK version knows. Requests (for example the `list` filter) accept only these.

Wire["PaymentStatus"]

PaymentEventType

"payment.created" | "payment.paid" | "payment.cancelled" | "payment.reversed"

PaymentMethod

KnownPaymentMethod | (string & {})

PaymentStatus

What a response may contain. The API only ever adds values, so a value newer than this SDK is passed through as a string instead of failing — `(string & {})` keeps autocompletion and narrowing for the known ones. Always keep a default branch when switching on these.

KnownPaymentStatus | (string & {})

WebhookEvent

Narrow on `event.type`; always handle (or ignore) `"unknown"`.

| PaymentEvent<"payment.created"> | PaymentEvent<"payment.paid"> | PaymentEvent<"payment.cancelled"> | PaymentEvent<"payment.reversed"> | EndpointTestEvent | UnknownWebhookEvent

API reference

Generated from the canonical OpenAPI document (API version 1.0.0). Download devzity-v1.yaml

Payments

Create a hosted Payment

POST/v1/payments

Creates a PENDING, automatically-verifiable hosted Payment and returns its `checkout_url`. Only a UX-level check runs at creation (an enabled, Bridge-ready receiving method and enough Service Credit for the next fee) — the real fee reservation happens when the customer reaches checkout.

Authentication
Secret key (Authorization: Bearer)
Required scope
payments:write
Idempotency-Key
recommended. See Idempotency

Parameters

FieldTypeDescription
Idempotency-Key (header)stringA merchant-generated key. The same key + an identical request body returns the original result without creating a duplicate Payment. The same key with a different body is a 409 idempotency_conflict. Retained 24 hours.

Request body CreatePaymentRequest

FieldTypeDescription
amount*stringDecimal string in BDT major units, e.g. "500.00". Never a JSON number.
descriptionstring
merchant_referencestringHuman/business reference — distinct from external_id.
external_idstringYour own correlation identifier. Unique per business (per livemode) when set.
return_urlstringWhere hosted checkout offers to return the customer after a terminal state. An absolute https URL or a native-app deep link scheme (myapp://..., com.example.app://...). javascript:/data:/file:/intent: are always rejected. Never authoritative proof of payment — always confirm status via this API or a webhook.
metadataMetadataBounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.
allowed_methodsPaymentMethod[]
allowed_channelsCollectionChannel[]
customer_idstring
order_idstring

Responses

FieldTypeDescription
id*string
object*stringpayment
livemode*boolean
status*PaymentStatusOnly statuses a Developer API consumer can actually observe. REVERSED reflects Devzity's own financial record only — it does not imply a provider-side fund transfer back to the customer. Additive-evolution policy: new statuses may be added in a later release of API v1, so clients must tolerate an unknown value when reading a response (request filters stay strict).
amount*string
currency*stringBDT
descriptionstring | null
merchant_referencestring | null
external_idstring | null
metadataMetadataBounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.
allowed_methodsPaymentMethod[]
allowed_channelsCollectionChannel[]
return_urlstring | null
checkout_urlstring | null
created_at*string (date-time)
paid_atstring | null (date-time)
cancelled_atstring | null (date-time)
Example · Shell
curl $DEVZITY_API_URL/v1/payments \
  -X POST \
  -H "Authorization: Bearer $DEVZITY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-123-payment" \
  -d '{
    "amount": "500.00",
    "external_id": "order-123",
    "return_url": "https://example.com/payment-return"
  }'

List Payments

GET/v1/payments

Cursor-paginated. `payments:read` scope required.

Authentication
Secret key (Authorization: Bearer)
Required scope
payments:read
Pagination
cursor (limit, starting_after), see Pagination

Parameters

FieldTypeDescription
status (query)PaymentStatus
external_id (query)string
created_from (query)string (date-time)
created_to (query)string (date-time)
limit (query)integer
starting_after (query)stringA Payment id — returns results immediately after it.

Responses

FieldTypeDescription
object*stringlist
data*Payment[]
has_more*boolean
next_cursorstring | null

Retrieve a Payment

GET/v1/payments/{id}

Authentication
Secret key (Authorization: Bearer)
Required scope
payments:read

Parameters

FieldTypeDescription
id (path)*string

Responses

FieldTypeDescription
id*string
object*stringpayment
livemode*boolean
status*PaymentStatusOnly statuses a Developer API consumer can actually observe. REVERSED reflects Devzity's own financial record only — it does not imply a provider-side fund transfer back to the customer. Additive-evolution policy: new statuses may be added in a later release of API v1, so clients must tolerate an unknown value when reading a response (request filters stay strict).
amount*string
currency*stringBDT
descriptionstring | null
merchant_referencestring | null
external_idstring | null
metadataMetadataBounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.
allowed_methodsPaymentMethod[]
allowed_channelsCollectionChannel[]
return_urlstring | null
checkout_urlstring | null
created_at*string (date-time)
paid_atstring | null (date-time)
cancelled_atstring | null (date-time)

Cancel a pending Payment

POST/v1/payments/{id}/cancel

Only valid while the Payment is PENDING. Cancels any active checkout attempt and releases its Service Credit fee reservation atomically. `payments:write` scope required.

Authentication
Secret key (Authorization: Bearer)
Required scope
payments:write

Parameters

FieldTypeDescription
id (path)*string

Responses

FieldTypeDescription
id*string
object*stringpayment
livemode*boolean
status*PaymentStatusOnly statuses a Developer API consumer can actually observe. REVERSED reflects Devzity's own financial record only — it does not imply a provider-side fund transfer back to the customer. Additive-evolution policy: new statuses may be added in a later release of API v1, so clients must tolerate an unknown value when reading a response (request filters stay strict).
amount*string
currency*stringBDT
descriptionstring | null
merchant_referencestring | null
external_idstring | null
metadataMetadataBounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.
allowed_methodsPaymentMethod[]
allowed_channelsCollectionChannel[]
return_urlstring | null
checkout_urlstring | null
created_at*string (date-time)
paid_atstring | null (date-time)
cancelled_atstring | null (date-time)

Test helpers

Simulate a successful payment (TEST SECRET KEYS ONLY)

POST/v1/test_helpers/payments/{id}/succeed

**TEST SECRET KEYS ONLY.** Moves a PENDING `livemode: false` Payment to PAID with no real money, SMS, Bridge, or Service Credit involved, and emits `payment.paid` to Test webhook endpoints. A `dz_live_sk_...` key receives 404, exactly as if this route did not exist. `payments:write` scope required.

TEST SECRET KEYS ONLY. A live key receives 404

Authentication
Secret key (Authorization: Bearer)
Required scope
payments:write

Parameters

FieldTypeDescription
id (path)*string

Responses

FieldTypeDescription
id*string
object*stringpayment
livemode*boolean
status*PaymentStatusOnly statuses a Developer API consumer can actually observe. REVERSED reflects Devzity's own financial record only — it does not imply a provider-side fund transfer back to the customer. Additive-evolution policy: new statuses may be added in a later release of API v1, so clients must tolerate an unknown value when reading a response (request filters stay strict).
amount*string
currency*stringBDT
descriptionstring | null
merchant_referencestring | null
external_idstring | null
metadataMetadataBounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.
allowed_methodsPaymentMethod[]
allowed_channelsCollectionChannel[]
return_urlstring | null
checkout_urlstring | null
created_at*string (date-time)
paid_atstring | null (date-time)
cancelled_atstring | null (date-time)

Simulate a cancelled payment (TEST SECRET KEYS ONLY)

POST/v1/test_helpers/payments/{id}/cancel

**TEST SECRET KEYS ONLY.** Moves a PENDING `livemode: false` Payment to CANCELLED and emits `payment.cancelled` to Test webhook endpoints. A `dz_live_sk_...` key receives 404. `payments:write` scope required.

TEST SECRET KEYS ONLY. A live key receives 404

Authentication
Secret key (Authorization: Bearer)
Required scope
payments:write

Parameters

FieldTypeDescription
id (path)*string

Responses

FieldTypeDescription
id*string
object*stringpayment
livemode*boolean
status*PaymentStatusOnly statuses a Developer API consumer can actually observe. REVERSED reflects Devzity's own financial record only — it does not imply a provider-side fund transfer back to the customer. Additive-evolution policy: new statuses may be added in a later release of API v1, so clients must tolerate an unknown value when reading a response (request filters stay strict).
amount*string
currency*stringBDT
descriptionstring | null
merchant_referencestring | null
external_idstring | null
metadataMetadataBounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.
allowed_methodsPaymentMethod[]
allowed_channelsCollectionChannel[]
return_urlstring | null
checkout_urlstring | null
created_at*string (date-time)
paid_atstring | null (date-time)
cancelled_atstring | null (date-time)

Schemas

PaymentStatus

Only statuses a Developer API consumer can actually observe. REVERSED reflects Devzity's own financial record only — it does not imply a provider-side fund transfer back to the customer. Additive-evolution policy: new statuses may be added in a later release of API v1, so clients must tolerate an unknown value when reading a response (request filters stay strict).

One of: PENDINGPAIDCANCELLEDREVERSED

PaymentMethod

Provider codes. New providers may be added, so clients must tolerate an unknown value when reading a response.

One of: BKASHNAGAD

CollectionChannel

How the customer pays. SEND_MONEY = Send Money, PAYMENT = Payment, CASH_IN = Cash In, BANK_TRANSFER = Bank / Card Add Money. Clients must tolerate values added in the future when READING a response.

One of: SEND_MONEYPAYMENTCASH_INBANK_TRANSFER

Metadata

Bounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.

CreatePaymentRequest

FieldTypeDescription
amount*stringDecimal string in BDT major units, e.g. "500.00". Never a JSON number.
descriptionstring
merchant_referencestringHuman/business reference — distinct from external_id.
external_idstringYour own correlation identifier. Unique per business (per livemode) when set.
return_urlstringWhere hosted checkout offers to return the customer after a terminal state. An absolute https URL or a native-app deep link scheme (myapp://..., com.example.app://...). javascript:/data:/file:/intent: are always rejected. Never authoritative proof of payment — always confirm status via this API or a webhook.
metadataMetadataBounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.
allowed_methodsPaymentMethod[]
allowed_channelsCollectionChannel[]
customer_idstring
order_idstring

Payment

FieldTypeDescription
id*string
object*stringpayment
livemode*boolean
status*PaymentStatusOnly statuses a Developer API consumer can actually observe. REVERSED reflects Devzity's own financial record only — it does not imply a provider-side fund transfer back to the customer. Additive-evolution policy: new statuses may be added in a later release of API v1, so clients must tolerate an unknown value when reading a response (request filters stay strict).
amount*string
currency*stringBDT
descriptionstring | null
merchant_referencestring | null
external_idstring | null
metadataMetadataBounded, string-only, developer-supplied context. Never shown to the customer, never used for financial matching. At most 20 keys; each key at most 64 characters; each value at most 500 characters.
allowed_methodsPaymentMethod[]
allowed_channelsCollectionChannel[]
return_urlstring | null
checkout_urlstring | null
created_at*string (date-time)
paid_atstring | null (date-time)
cancelled_atstring | null (date-time)

PaymentList

FieldTypeDescription
object*stringlist
data*Payment[]
has_more*boolean
next_cursorstring | null

WebhookEventType

Only events for transitions that actually exist. `endpoint.test` is a synthetic, non-financial event sent from the dashboard to Test endpoints.

One of: payment.createdpayment.paidpayment.cancelledpayment.reversedendpoint.test

Event

The stable webhook envelope. `id` is generated once per logical event and reused on every retry — your idempotency key. `data.object` is exactly the Payment returned by `GET /v1/payments/{id}` at the moment of the event; the payload is snapshotted at creation and never regenerated for retries.

FieldTypeDescription
id*stringevt_...
object*stringevent
api_version*stringv1
type*WebhookEventTypeOnly events for transitions that actually exist. `endpoint.test` is a synthetic, non-financial event sent from the dashboard to Test endpoints.
livemode*boolean
created_at*string (date-time)
data*object

WebhookPaymentEvent

A `payment.*` event — `data.object` is a Payment.

FieldTypeDescription
id*stringevt_...
object*stringevent
api_version*stringv1
type*stringpayment.created · payment.paid · payment.cancelled · payment.reversed
livemode*boolean
created_at*string (date-time)
data*object

Error

Every error response has this shape. `code` is a stable, machine-readable string — clients must treat it as an open string (new codes may be added). Always quote `request_id` to support.

FieldTypeDescription
error*object