Introduction
MarkdownAccept 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.
Your server ──(secret key)──▶ Devzity API ──▶ checkout_url ──▶ your customer pays
│
└──▶ signed webhook ──▶ your server fulfils the orderStart 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#
| You use | For |
|---|---|
| Node.js SDK | Node.js / TypeScript servers |
| Next.js helpers | Next.js App Router routes and webhooks |
| REST | PHP, Laravel, Java / Spring Boot, Go, Python, curl — anything that speaks HTTP; available today |
| Android and iOS checkout SDKs | Opening checkout from a native app and handling the return |
| React Native and Flutter | The same pattern with standard link handling |
Concepts in one minute#
- Payment — one request to collect an amount. It starts
PENDINGand endsPAID,CANCELLEDorREVERSED. - 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
MarkdownAccept your first test payment in a few minutes — no real money, no payment account.
The whole idea#
- Use test mode. It needs no bKash or Nagad account.
- Create a test secret key in the dashboard.
- The secret key lives on your server, never in browser or app code.
- Create a Payment from your server.
- Open its
checkoutUrlfor the customer. - Simulate success with one call.
- 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:
export DEVZITY_SECRET_KEY=dz_test_sk_...
export DEVZITY_API_URL=https://api.devzity.com2. 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#
// @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?
#!/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:
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.
// 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:
| Where | What you see |
|---|---|
Your console.log | a checkoutUrl |
| The payment | status: PAID after step 5 |
| Your webhook endpoint | a verified payment.paid event with livemode: false |
| The dashboard → Developers → Webhooks | the 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
MarkdownSecret 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:
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.
| Prefix | Environment | Moves real money? |
|---|---|---|
dz_test_sk_ | Test | No — sandbox only |
dz_live_sk_ | Live | Yes |
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#
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
MarkdownPoint 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#
| What | Address |
|---|---|
Index of every page (the llms.txt standard) | https://docs.devzity.com/llms.txt |
| Every page in one file | https://docs.devzity.com/llms-full.txt |
| One page as Markdown | https://docs.devzity.com/v1/<page>.md, for example https://docs.devzity.com/v1/webhooks.md |
| The API as OpenAPI 3 | https://docs.devzity.com/openapi/devzity-v1.yaml |
- Claude Code, Cursor, Windsurf: paste the
llms-full.txtaddress 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.
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/paymentssends anIdempotency-Key. - The webhook handler verifies
Devzity-Signatureon 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#
- Quickstart: create a payment, open the checkout, receive the webhook.
- Authentication: keys, test and live.
- Webhooks: signature, retries, ordering.
- Checkout: return URL and why it is only a hint.
- Idempotency and Errors.
- Mobile apps: the app only opens the checkout URL; your backend does the rest.
Payments
MarkdownCreate, 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#
const payment = await devzity.payments.create({
amount: 500,
externalId: "order-123",
returnUrl: "https://shop.example/payment-return",
metadata: { plan: "pro" },
})| Field | Notes |
|---|---|
amount | Required. Taka as a decimal string with at most two decimals, e.g. "500.00". |
external_id | Your 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_reference | A human reference shown on receipts. Not unique. |
description | What the customer is paying for. |
metadata | Up to 20 string key/value pairs (keys ≤ 64 characters, values ≤ 500). Never shown to the customer. |
return_url | Where checkout offers to send the customer afterwards. See Checkout. |
allowed_methods | Restrict to BKASH and/or NAGAD. Default: all your enabled providers. |
allowed_channels | Restrict 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)#
| API value | Shown to the customer as |
|---|---|
SEND_MONEY | Send Money |
PAYMENT | Payment |
CASH_IN | Cash In |
BANK_TRANSFER | Bank / Card Add Money |
Each type is independent. BANK_TRANSFER payments don't ask for the customer's phone number.
Retrieve, list, cancel#
await devzity.payments.retrieve(payment.id)
await devzity.payments.list({ limit: 20, status: "PAID" })
await devzity.payments.cancel(payment.id) // only while PENDINGGET /v1/payments supports status, external_id, created_from, created_to, limit and starting_after. See Pagination.
Statuses#
| Status | Meaning |
|---|---|
PENDING | Created; waiting for the customer to pay. |
PAID | Payment confirmed. |
CANCELLED | Cancelled before payment. |
REVERSED | A 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
MarkdownSend customers to the hosted checkout page, and handle their return safely.
Checkout is Devzity's hosted payment page. You never build a payment form.
- Your server creates a Payment and receives
checkout_url. - You send the customer there — an HTTP redirect on the web, or opening the URL from a mobile app.
- The customer chooses bKash or Nagad, enters their number if needed, and pays.
- If you supplied a
return_url, checkout offers a Return to merchant button (or Return to app for native links). It never redirects automatically. - 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:
| Parameter | Value |
|---|---|
devzity_payment_id | The Payment id. |
devzity_status | PAID or CANCELLED. |
Correct handling, in any language:
on return with devzity_status=PAID:
refresh the order/payment from YOUR backend ← never fulfil hereYour 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
MarkdownReceive 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#
- Developers → Webhooks → Add endpoint. Choose the environment (Test or Live), enter your HTTPS URL, and pick the events you want (or all of them).
- Copy the signing secret (
whsec_…). It is shown once; rotate it any time from the endpoint page. - Store it as
DEVZITY_WEBHOOK_SECRETon 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#
| Payment | Delivered to |
|---|---|
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#
| Type | Sent when |
|---|---|
payment.created | A payment is created. |
payment.paid | A payment becomes PAID. |
payment.cancelled | A payment is cancelled. |
payment.reversed | A paid payment is reversed in Devzity's records (not an automatic provider refund). |
endpoint.test | You 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).
{
"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.
// 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 callsrequest.text()), or callrequest.text()yourself. Never callrequest.json()first: the body can only be read once. - Express — mount
express.raw({ type: "application/json" })for the webhook route, before anyexpress.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#
- Verify the signature (above).
- Deduplicate on
event.id. If you have already processed that id, return200and do nothing. - Fulfil only from a verified
payment.paid— then mark the id processed.
if event already processed → return 200
if event.type == "payment.paid" → fulfil the order for event.data.object
record event.id as processed → return 200Keep 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
2xxquickly to acknowledge. Redirects are not followed;3xx,4xx,5xxand 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
idand the same body. - Do not rely on arrival order.
payment.createdmay arrive afterpayment.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
MarkdownTest 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 havelivemode: 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_channelsandreturn_urlbehave 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:
await devzity.testHelpers.payments.succeed(payment.id) // → PAID
await devzity.testHelpers.payments.cancel(payment.id) // → CANCELLEDThese 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
MarkdownA 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.paidwebhook 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:
| Code | Fix |
|---|---|
payment_detection_not_ready | No enabled, ready receiving method yet — configure an account and confirm detection. |
payment_method_unavailable | None of your allowed_methods is usable right now. |
payment_channel_unavailable | No ready receiving number accepts the requested allowed_channels. |
insufficient_credit | Your 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
MarkdownYou 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.
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.
// 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#
- Sends the order id to your backend and receives
checkout_url. - Opens it:
DevzityCheckout.open(...)on Android orDevzityCheckout.present(...)on iOS; with the system browser on React Native and Flutter. - 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
MarkdownOpen 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:
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:
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.
3. Receive the return link#
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:
<?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:
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#
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.
iOS
MarkdownOpen 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:
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#
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#
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.
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"
}
}Universal Links (recommended)#
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:
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
MarkdownAccept 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.
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#
- The app sends an order id to your backend; the backend decides the amount and returns
checkout_url. - The app opens it with
Linking.openURL. - Your backend set the payment's
return_urlto your app's deep link (myapp://payment-return). Configure that scheme in your app (schemein Expo'sapp.json, or native URL types). - When the app receives the link, ask your backend for the order's status.
// 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
MarkdownAccept 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.
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#
- The app sends an order id to your backend; the backend decides the amount and returns
checkout_url. - Open it with
url_launcher(LaunchMode.externalApplication). - Your backend set the payment's
return_urlto your app's deep link. Register it as an Android intent filter / App Link and an iOS URL scheme / Universal Link, and listen withapp_links. - When the link arrives, ask your backend for the order's status.
// 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
MarkdownCreate 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:
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 anIdempotency-Key(see Idempotency). - Amounts are strings:
"500.00".
Pick your language:
curl#
#!/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)#
// 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#
<?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 appLaravel#
<?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:
# 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#
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");
}
}# 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#
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 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:
{ "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
MarkdownThe error format, every error code, and what to send support.
Errors use standard HTTP status codes and one JSON shape:
{
"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"
}
}codeis stable and machine-readable — switch on it. New codes can be added, so keep a default branch.messageis for humans and may change.paramnames 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#
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#
| Code | HTTP | Meaning |
|---|---|---|
| invalid_api_key | 401 | The API key is missing, malformed, unknown, or revoked. |
| insufficient_scope | 403 | The key lacks the scope (payments:read or payments:write) this endpoint needs. |
| subscription_expired | 402 | The business subscription has expired; renew it before using the API key again. |
| rate_limit_exceeded | 429 | Too many requests for this key. Honor the Retry-After header. |
| invalid_request | 400 | The request body or a parameter is malformed. |
| invalid_amount | 400 | amount must be a positive decimal string with at most two decimals, e.g. "500.00". |
| invalid_external_id | 400 | external_id is invalid or too long. |
| invalid_metadata | 400 | metadata exceeds its limits (20 keys, 64-char keys, 500-char values). |
| invalid_return_url | 400 | return_url must be an absolute https URL or a native app deep link. |
| invalid_allowed_method | 400 | allowed_methods must only name BKASH or NAGAD. |
| invalid_allowed_channel | 400 | allowed_channels must only name SEND_MONEY, PAYMENT, CASH_IN or BANK_TRANSFER. |
| description_too_long | 400 | description is too long. |
| reference_too_long | 400 | merchant_reference is too long. |
| invalid_customer | 400 | customer_id does not belong to your business. |
| order_not_found | 400 | order_id does not exist for your business. |
| order_customer_mismatch | 400 | The payment's customer does not match the order's customer. |
| exceeds_order_due | 400 | amount exceeds the order's remaining due. |
| context_items_not_accepted | 400 | context_items are not accepted by the Developer API. |
| payment_not_found | 404 | No hosted payment with this id exists for this key's business and environment. |
| not_found | 404 | No such API route. |
| method_not_allowed | 405 | The HTTP method is not allowed for this route. |
| external_id_conflict | 409 | A payment with this external_id already exists (per business and environment). |
| idempotency_conflict | 409 | This Idempotency-Key was already used with a different request. |
| idempotency_in_progress | 409 | A request with this Idempotency-Key is still being processed; retry shortly. |
| invalid_transition | 409 | The payment is not in a state that allows this action (for example, cancelling a paid payment). |
| order_cancelled | 409 | The referenced order is cancelled. |
| payment_method_unavailable | 409 | None of the allowed payment methods are currently usable (live mode). |
| payment_channel_unavailable | 409 | No ready receiving number accepts the requested payment type (live mode). |
| payment_detection_not_ready | 409 | The business has no enabled, ready receiving method yet (live mode). |
| insufficient_credit | 409 | The business does not have enough Service Credit for the next fee (live mode). |
| internal_error | 500 | Something 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
MarkdownWhat to do when a request fails — start with the request_id.
First, copy the request_id. Every error response has one:
{ "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#
| You see | Usually means | What to do |
|---|---|---|
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 fails | You 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 arrives | Wrong 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
MarkdownSafely 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:
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.
Pagination
MarkdownList payments page by page with cursors, or let the SDK page for you.
GET /v1/payments returns newest first, one page at a time, using a cursor.
| Parameter | Meaning |
|---|---|
limit | 1–100, default 20. |
starting_after | A payment id: return the payments after it. Use the previous page's next_cursor. |
{ "object": "list", "data": [ … ], "has_more": true, "next_cursor": "9d1f4c3e-…" }Repeat with starting_after=<next_cursor> until has_more is false. You can combine paging with status, external_id, created_from and created_to.
With the SDK#
One page:
const page = await devzity.payments.list({ limit: 20 })
console.log(page.data, page.hasMore, page.nextCursor)Or iterate everything (pages are fetched lazily):
for await (const payment of devzity.payments.listAutoPaging({ status: "PAID" })) {
console.log(payment.id)
}Versioning and stability
MarkdownWhat 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
MarkdownThe @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).
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): stringverifyWebhook
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): WebhookEventConstants
DEFAULT_TOLERANCE_SECONDS
Reject signatures older (or further in the future) than this.
300SDK_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 | UnknownWebhookEventAPI 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
| Field | Type | Description |
|---|---|---|
| Idempotency-Key (header) | string | A 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
| Field | Type | Description |
|---|---|---|
| amount* | string | Decimal string in BDT major units, e.g. "500.00". Never a JSON number. |
| description | string | |
| merchant_reference | string | Human/business reference — distinct from external_id. |
| external_id | string | Your own correlation identifier. Unique per business (per livemode) when set. |
| return_url | string | Where 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. |
| metadata | 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. |
| allowed_methods | PaymentMethod[] | |
| allowed_channels | CollectionChannel[] | |
| customer_id | string | |
| order_id | string |
Responses
| Field | Type | Description |
|---|---|---|
| id* | string | |
| object* | string | payment |
| livemode* | boolean | |
| status* | 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). |
| amount* | string | |
| currency* | string | BDT |
| description | string | null | |
| merchant_reference | string | null | |
| external_id | string | null | |
| metadata | 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. |
| allowed_methods | PaymentMethod[] | |
| allowed_channels | CollectionChannel[] | |
| return_url | string | null | |
| checkout_url | string | null | |
| created_at* | string (date-time) | |
| paid_at | string | null (date-time) | |
| cancelled_at | string | null (date-time) |
201Payment created, Payment400Invalid request, Error, see error codes401Missing, invalid, or revoked API key, Error, see error codes402The business subscription has expired, Error, see error codes403The key does not have the required scope, Error, see error codes409State/idempotency/external_id conflict, Error, see error codes429Too many requests, Error, see error codes
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
| Field | Type | Description |
|---|---|---|
| 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) | string | A Payment id — returns results immediately after it. |
Responses
| Field | Type | Description |
|---|---|---|
| object* | string | list |
| data* | Payment[] | |
| has_more* | boolean | |
| next_cursor | string | null |
200A page of Payments, PaymentList401Missing, invalid, or revoked API key, Error, see error codes402The business subscription has expired, Error, see error codes403The key does not have the required scope, Error, see error codes429Too many requests, Error, see error codes
Retrieve a Payment
GET/v1/payments/{id}
- Authentication
- Secret key (Authorization: Bearer)
- Required scope
payments:read
Parameters
| Field | Type | Description |
|---|---|---|
| id (path)* | string |
Responses
| Field | Type | Description |
|---|---|---|
| id* | string | |
| object* | string | payment |
| livemode* | boolean | |
| status* | 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). |
| amount* | string | |
| currency* | string | BDT |
| description | string | null | |
| merchant_reference | string | null | |
| external_id | string | null | |
| metadata | 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. |
| allowed_methods | PaymentMethod[] | |
| allowed_channels | CollectionChannel[] | |
| return_url | string | null | |
| checkout_url | string | null | |
| created_at* | string (date-time) | |
| paid_at | string | null (date-time) | |
| cancelled_at | string | null (date-time) |
200The Payment, Payment401Missing, invalid, or revoked API key, Error, see error codes402The business subscription has expired, Error, see error codes403The key does not have the required scope, Error, see error codes404Not found (including a Payment belonging to a different business), Error, see error codes429Too many requests, Error, see error codes
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
| Field | Type | Description |
|---|---|---|
| id (path)* | string |
Responses
| Field | Type | Description |
|---|---|---|
| id* | string | |
| object* | string | payment |
| livemode* | boolean | |
| status* | 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). |
| amount* | string | |
| currency* | string | BDT |
| description | string | null | |
| merchant_reference | string | null | |
| external_id | string | null | |
| metadata | 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. |
| allowed_methods | PaymentMethod[] | |
| allowed_channels | CollectionChannel[] | |
| return_url | string | null | |
| checkout_url | string | null | |
| created_at* | string (date-time) | |
| paid_at | string | null (date-time) | |
| cancelled_at | string | null (date-time) |
200The cancelled Payment, Payment401Missing, invalid, or revoked API key, Error, see error codes402The business subscription has expired, Error, see error codes403The key does not have the required scope, Error, see error codes404Not found (including a Payment belonging to a different business), Error, see error codes409State/idempotency/external_id conflict, Error, see error codes429Too many requests, Error, see error codes
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
| Field | Type | Description |
|---|---|---|
| id (path)* | string |
Responses
| Field | Type | Description |
|---|---|---|
| id* | string | |
| object* | string | payment |
| livemode* | boolean | |
| status* | 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). |
| amount* | string | |
| currency* | string | BDT |
| description | string | null | |
| merchant_reference | string | null | |
| external_id | string | null | |
| metadata | 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. |
| allowed_methods | PaymentMethod[] | |
| allowed_channels | CollectionChannel[] | |
| return_url | string | null | |
| checkout_url | string | null | |
| created_at* | string (date-time) | |
| paid_at | string | null (date-time) | |
| cancelled_at | string | null (date-time) |
200The PAID Payment, Payment401Missing, invalid, or revoked API key, Error, see error codes402The business subscription has expired, Error, see error codes403The key does not have the required scope, Error, see error codes404Not found (including a Payment belonging to a different business), Error, see error codes409State/idempotency/external_id conflict, Error, see error codes429Too many requests, Error, see error codes
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
| Field | Type | Description |
|---|---|---|
| id (path)* | string |
Responses
| Field | Type | Description |
|---|---|---|
| id* | string | |
| object* | string | payment |
| livemode* | boolean | |
| status* | 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). |
| amount* | string | |
| currency* | string | BDT |
| description | string | null | |
| merchant_reference | string | null | |
| external_id | string | null | |
| metadata | 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. |
| allowed_methods | PaymentMethod[] | |
| allowed_channels | CollectionChannel[] | |
| return_url | string | null | |
| checkout_url | string | null | |
| created_at* | string (date-time) | |
| paid_at | string | null (date-time) | |
| cancelled_at | string | null (date-time) |
200The CANCELLED Payment, Payment401Missing, invalid, or revoked API key, Error, see error codes402The business subscription has expired, Error, see error codes403The key does not have the required scope, Error, see error codes404Not found (including a Payment belonging to a different business), Error, see error codes409State/idempotency/external_id conflict, Error, see error codes429Too many requests, Error, see error codes
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
| Field | Type | Description |
|---|---|---|
| amount* | string | Decimal string in BDT major units, e.g. "500.00". Never a JSON number. |
| description | string | |
| merchant_reference | string | Human/business reference — distinct from external_id. |
| external_id | string | Your own correlation identifier. Unique per business (per livemode) when set. |
| return_url | string | Where 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. |
| metadata | 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. |
| allowed_methods | PaymentMethod[] | |
| allowed_channels | CollectionChannel[] | |
| customer_id | string | |
| order_id | string |
Payment
| Field | Type | Description |
|---|---|---|
| id* | string | |
| object* | string | payment |
| livemode* | boolean | |
| status* | 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). |
| amount* | string | |
| currency* | string | BDT |
| description | string | null | |
| merchant_reference | string | null | |
| external_id | string | null | |
| metadata | 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. |
| allowed_methods | PaymentMethod[] | |
| allowed_channels | CollectionChannel[] | |
| return_url | string | null | |
| checkout_url | string | null | |
| created_at* | string (date-time) | |
| paid_at | string | null (date-time) | |
| cancelled_at | string | null (date-time) |
PaymentList
| Field | Type | Description |
|---|---|---|
| object* | string | list |
| data* | Payment[] | |
| has_more* | boolean | |
| next_cursor | string | 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.
| Field | Type | Description |
|---|---|---|
| id* | string | evt_... |
| object* | string | event |
| api_version* | string | v1 |
| type* | WebhookEventType | Only 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.
| Field | Type | Description |
|---|---|---|
| id* | string | evt_... |
| object* | string | event |
| api_version* | string | v1 |
| type* | string | payment.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.
| Field | Type | Description |
|---|---|---|
| error* | object |
