Accept Iranian Rial and Toman payments on an EmDash CMS site through the Zarinpal gateway. Built for EmDash on Cloudflare Workers, and it also runs on Node.js.
- Payment links. Create a fixed-price link in the admin, paste its button on any page, and take payments without writing server code.
- Server-side checkout API. Your own code sets the price and the order ID, then sends the buyer to Zarinpal.
- Verified callbacks. A payment becomes paid only when Zarinpal's verify call confirms it. The
Status=OKin the return URL is never trusted. - Automatic reconciliation. A cron job re-verifies unsettled payments in the background, so a buyer who paid and closed the tab is not lost. An outage never makes the plugin give up on a payment.
- Admin ledger. Filter payments, see Zarinpal reference IDs, card masks and fees, re-verify one payment, and watch a dashboard widget.
- Sandboxed and least-privilege. In EmDash's plugin sandbox it can only reach
payment.zarinpal.comandsandbox.zarinpal.com, and cannot read your content, media or users. On the Cloudflare free plan you can run it in-process instead, without that isolation. See Choose how the plugin runs.
Tested against the live Zarinpal sandbox on 19 October 2026 with EmDash 1.3.1. See Testing.
Project page: https://taleghanidev.github.io/emdash-plugin-zarinpal/
- How a payment works
- Requirements
- Install on Cloudflare Workers
- Install on Node.js
- Configure the plugin
- Take payments with a payment link
- Take payments from your own code
- Fulfil orders safely
- Go live
- Route reference
- Payment states and reconciliation
- Security
- Known limitations
- Testing
- Development
- راهنمای فارسی
Buyer Your site (EmDash + plugin) Zarinpal
│ click Pay ─────► create payment record
│ POST request.json ─────────► authority
│ ◄───────────────── StartPay URL
│ pay at the bank ──────────────────────────────►
│ ◄──────────────────────────────── redirect to /callback?Authority=…&Status=…
│ POST verify.json ──────────► 100 / 101 + ref_id
│ ◄───────────────── 303 to /payment/success?payment=…&token=…
- The plugin stores the payment first, then asks Zarinpal for an authority.
- The buyer pays on Zarinpal's page. Zarinpal sends them back to the plugin's callback route.
- The callback calls Zarinpal's
verifywith the amount and currency stored on the payment. Only a100or101answer marks it paid. - The buyer lands on your success page with a receipt token. Your server asks the plugin for the authoritative status before fulfilling the order.
| What | Why |
|---|---|
| EmDash 1.3 or newer | The plugin uses the 1.3 sandbox route contract. |
| A sandbox runner, or in-process mode | On Cloudflare the sandbox is Worker Loader, which needs the Workers Paid plan. On the free plan, run the plugin in-process instead. On Node.js the sandbox is @emdash-cms/sandbox-workerd. |
EMDASH_ENCRYPTION_KEY |
The merchant ID and API key are encrypted settings. |
| A Zarinpal merchant ID | Any UUID works in sandbox mode. For live payments the terminal's registered domain must match your site, or Zarinpal answers -14. |
These steps assume a site created from an EmDash *-cloudflare template, which already has a src/worker.ts.
EmDash can run this plugin in two ways on Cloudflare:
| Sandboxed (recommended) | In-process | |
|---|---|---|
| Cloudflare plan | Workers Paid | Free or Paid |
| Registered under | sandboxed: [zarinpal] with sandboxRunner: sandbox() |
plugins: [zarinpal] |
| Isolation | Own Dynamic Worker; can only reach Zarinpal's two hosts | Runs inside your site's Worker with its full authority |
| Limits | 10 subrequests and 50 ms CPU per plugin call | Your Worker's limits, shared with EmDash. Workers Free allows 10 ms CPU per request |
Why sandboxing needs the paid plan: EmDash runs each sandboxed plugin as a separate Dynamic Worker, created through the Worker Loader binding, and Cloudflare offers Dynamic Workers only on the Workers Paid plan. Without the binding, EmDash skips plugins listed under sandboxed: [].
On the free plan, list the plugin under plugins: [] and skip the rest of this step. EmDash's own docs describe this as running a sandboxed plugin in-process. The plugin's ctx calls are still capability-checked, but there is no isolation boundary: in-process code runs with your Worker's full authority, including its secrets and bindings. Install it this way only if you trust the code, which you can read in src/.
What was checked for in-process mode: on the Cloudflare template with no Worker Loader binding, the plugin loads, its admin pages render and its routes answer. A full payment on a deployed free-plan Worker has not been run yet. The plugin's own work per request is small, but it shares the free plan's 10 ms CPU budget with EmDash.
To use the sandbox, enable Worker Loader. If you created the site with --sandboxed-plugins, this is already done.
Uncomment the binding in wrangler.jsonc:
Check that src/worker.ts exports PluginBridge and the scheduled handler. The reconciliation job needs the scheduled handler and the template's cron trigger:
import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
export { PluginBridge };
export default {
...handler,
scheduled: createScheduledHandler(),
} satisfies ExportedHandler;pnpm add github:taleghanidev/emdash-plugin-zarinpalThe built files in dist/ are committed, so no build step runs on install. Pin a release with github:taleghanidev/emdash-plugin-zarinpal#v0.1.1.
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { d1, r2, sandbox } from "@emdash-cms/cloudflare";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import zarinpal from "emdash-plugin-zarinpal";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
react(),
emdash({
database: d1({ binding: "DB", session: "auto", coalesce: true }),
storage: r2({ binding: "MEDIA" }),
sandboxRunner: sandbox(),
sandboxed: [zarinpal],
// Free plan instead: remove the two lines above and use
// plugins: [zarinpal],
}),
],
});# Encrypts plugin secrets. Keep a copy outside Cloudflare: without it the
# stored merchant ID and API key cannot be decrypted.
npx emdash secrets generate
npx wrangler secret put EMDASH_ENCRYPTION_KEY
# Only if you use the server-side checkout. Use the same value as the
# plugin's "Checkout API key" setting.
openssl rand -base64 32
npx wrangler secret put ZARINPAL_API_KEYFor local development put the same names in .dev.vars.
Copy the example files into your site. They use the template's src/layouts/Base.astro; change that import if your layout lives elsewhere.
Copy from examples/astro/src/ |
Purpose |
|---|---|
lib/zarinpal.ts |
Calls plugin routes in-process from Astro code |
pages/payment/success.astro |
Receipt page after a verified payment |
pages/payment/failed.astro |
Page after a cancelled or failed payment |
components/ZarinpalButton.astro |
Pay button for a payment link |
pages/api/checkout.ts |
Optional server-side checkout endpoint |
pages/shop.astro |
Optional demo page with both payment methods |
cp -R node_modules/emdash-plugin-zarinpal/examples/astro/src/* src/pnpm run deployOpen the admin, go to Plugins, and continue with Configure the plugin.
pnpm add github:taleghanidev/emdash-plugin-zarinpal @emdash-cms/sandbox-workerd workerdimport node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
import zarinpal from "emdash-plugin-zarinpal";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
integrations: [
react(),
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
sandboxed: [zarinpal],
}),
],
});Set EMDASH_ENCRYPTION_KEY and, if you use checkout, ZARINPAL_API_KEY in the server's environment. Copy the example pages as in step 5 above.
Open Plugins, then the gear icon on zarinpal.
| Setting | What to enter |
|---|---|
| Merchant ID | Your 36-character merchant code. In sandbox mode any UUID works, for example 1344b5d4-0048-11e8-94db-005056a205be. |
| Sandbox mode | On while you test. No real money moves. |
| Default currency | Toman (IRT) or Rial (IRR). It applies to links and checkout calls that do not name a currency. |
| Public site URL | Your site's origin, such as https://example.ir. Leave it empty to use the URL from EmDash's settings. The callback URL is built from it. |
| Success / Failure page path | Default /payment/success and /payment/failed. Paths only, on your own site. |
| Checkout API key | A long random secret for the server routes. Leave empty to disable them. |
| New payments per minute | A site-wide cap on new payment attempts. Default 30. |
The Zarinpal payments page shows the callback URL the plugin sends to Zarinpal, so you can check it before going live.
-
Open Payment links, enter a title and an amount, and select Create link.
-
Expand Button code and copy it into a page, a template or an HTML block. In Astro you can use the bundled component instead:
--- import ZarinpalButton from "../components/ZarinpalButton.astro"; --- <ZarinpalButton link="lnk_GMloPG1ukBXd" label="خرید بلیت" askMobile />
The button posts the link ID to the plugin. The plugin takes the price from the link, never from the browser, and returns Zarinpal's payment URL. The script only navigates to a payment.zarinpal.com or sandbox.zarinpal.com address.
| Shop page | Zarinpal sandbox | Receipt |
|---|---|---|
![]() |
![]() |
![]() |
Disabling or deleting a link stops new payments. Its past payments stay in the ledger.
Use this when the price depends on a cart, a coupon or a logged-in user. Your server code calls the plugin's checkout route in-process through Astro.locals.emdash. On Cloudflare this matters, because a Worker that fetches its own hostname is not reliable.
// src/pages/api/checkout.ts (full version in examples/)
import type { APIRoute } from "astro";
import { zarinpal } from "../../lib/zarinpal";
export const POST: APIRoute = async ({ request, locals, url, redirect }) => {
const form = await request.formData();
const product = PRODUCTS[String(form.get("product"))]; // your own price list
const orderId = `ord-${crypto.randomUUID()}`;
const result = await zarinpal(locals, url, "checkout", {
body: {
apiKey: process.env.ZARINPAL_API_KEY,
amount: product.amount, // whole number, in `currency`
currency: "IRT",
description: product.title,
orderId,
idempotencyKey: orderId, // retrying with the same key never creates a second payment
},
});
if (!result.ok) return redirect(`/payment/failed?reason=${result.data.error}`, 303);
return redirect(result.data.paymentUrl, 303);
};On Cloudflare, process.env holds Worker secrets when nodejs_compat is on and the compatibility date is 2025-04-01 or later. The EmDash templates meet both conditions.
Never fulfil because the buyer reached the success page or because a URL says Status=OK. Ask the plugin, with your API key, and check the facts that matter to you:
const res = await zarinpal(locals, url, "payment", {
body: { apiKey: process.env.ZARINPAL_API_KEY, orderId, verify: true },
});
const p = res.data.payment;
if (p?.paid && p.amount === expectedAmount && p.currency === "IRT" && p.environment === "production") {
await markOrderPaidOnce(orderId, p.refId); // make this idempotent in your database
}verify: true re-verifies up to two unsettled attempts with Zarinpal before answering, which is useful when the buyer never came back. When an order has several attempts, a paid one is returned first, and attempts lists them all.
- Register your domain on your Zarinpal terminal. Its domain must match Public site URL.
- Enter the real Merchant ID and turn off Sandbox mode.
- Make one small real payment and check the ledger shows paid with a reference ID.
- Make sure your fulfilment code checks
environment === "production", so a sandbox payment never ships an order.
Payments keep the environment they were created in. Switching modes does not move old sandbox payments to production.
All routes live under /_emdash/api/plugins/zarinpal/. JSON routes answer inside EmDash's envelope: { "success": true, "data": { … } }. Errors are data.ok === false with an error code.
| Route | Method | Auth | Purpose |
|---|---|---|---|
pay |
POST JSON | public | Start a payment from a payment link. Body { link, email?, mobile? }. Returns { ok, paymentId, paymentUrl }. |
checkout |
POST JSON | API key in body | Start a payment from server code. Body { apiKey, amount, description, currency?, orderId?, idempotencyKey?, email?, mobile? }. Returns { ok, paymentId, authority, paymentUrl, reused, environment }. |
callback |
GET | public | Zarinpal's return URL. Verifies, then redirects with 303 to your success or failure path. |
receipt |
GET | receipt token | ?payment=&token= from the success redirect. Returns display fields only. |
payment |
POST JSON | API key in body | Authoritative status for fulfilment. Body { apiKey, paymentId } or { apiKey, orderId }, plus optional verify: true. |
admin |
POST | EmDash admin | Block Kit pages and widget. |
Checkout and payment error codes:
| Code | Meaning |
|---|---|
CHECKOUT_DISABLED |
No Checkout API key is set. |
UNAUTHORIZED |
Wrong API key. |
NOT_CONFIGURED, NO_SITE_URL |
Merchant ID or site URL missing. |
INVALID_AMOUNT |
Not a positive whole number, or above Zarinpal's limit of 100,000,000 Toman. |
INVALID_CURRENCY |
currency was given but is not exactly IRR or IRT. |
INVALID_DESCRIPTION |
Empty or over 500 characters. |
INVALID_ORDER_ID |
Use 1 to 64 of A-Z a-z 0-9 . _ : -. |
IDEMPOTENCY_CONFLICT |
The key was used with a different amount, order or customer. |
IN_PROGRESS |
Another request with the same key is starting this payment. Retry after a minute. |
ALREADY_PAID, PAYMENT_NOT_PAYABLE |
The key's payment is settled. Use a new key for a new attempt. |
RATE_LIMITED |
The per-minute cap was reached. |
GATEWAY_REJECTED |
Zarinpal refused the request. The ledger shows its code, for example -14 for a domain mismatch. |
UNKNOWN_LINK |
The payment link does not exist or is disabled. |
Failure redirect reasons: cancelled, verify_pending (Zarinpal could not be reached; reconciliation retries), zarinpal_<code> such as zarinpal_-51, unknown_payment, invalid_callback.
| State | Meaning | Final? |
|---|---|---|
pending |
Created; the buyer has not come back or verification has not run. | No |
paid |
Zarinpal's verify returned 100 or 101. |
Yes |
failed |
The last verify said not paid (-51 or -54). |
No: a later verify can still find it paid |
expired |
Zarinpal still said not paid 6 hours after the authority was issued. | Only until a callback or Verify now finds it paid |
error |
Zarinpal refused to create it, so it has no authority. | Yes |
Rules the plugin keeps:
- Only a successful verify writes
paid, and every write is guarded by the current state. A forged or repeated callback cannot downgrade a paid payment. Status=NOKis recorded as an observation and the buyer goes to the failure page, but the payment stays open for reconciliation.- Verify always uses the amount and currency stored on the payment. In the sandbox, an IRT payment verified with the Rial amount fails with
-50, so mixing units is never allowed. - Only Zarinpal's "not paid" answers,
-51and-54, can fail or expire a payment. A network error, a rate limit such as-12, or any other code keeps the payment scheduled, at least every 3 hours, without end. - Each payment remembers a fingerprint of the merchant ID that created it. If you change the merchant ID, older open payments are not verified against the new one, since Zarinpal would answer
-53. They stay open with a note in the ledger until you restore the old ID. - The cron job runs every minute and checks at most 3 due payments per run, oldest first, which is about 180 checks an hour. Each payment is checked about 20 minutes, 2 hours and 6 hours after its authority was issued.
Use Actions → Verify now on any unsettled row to check one payment immediately, or Reconcile now to process due payments.
- Least privilege. The only capability is
network:request, limited to Zarinpal's two hosts. - Secrets. The merchant ID and API key are stored encrypted with
EMDASH_ENCRYPTION_KEY. Keep the API key on the server. Never put it in browser code. - Prices. Payment links take the price from the stored link. Checkout takes it from your server code. The browser never sets an amount.
- No open redirects. Return pages are paths on your own site. EmDash also blocks public plugin routes from redirecting to another origin.
- Receipts. The success URL carries a random receipt token, and the
receiptroute returns only display fields. - Abuse. Payment creation is POST-only, so crawlers and link previews never create payments, and a per-minute cap protects your Zarinpal terminal from error
-12.
- Local
astro devwith the Cloudflare adapter cannot reach Zarinpal. The local workerd runtime fails the TLS connection to Zarinpal's ArvanCloud edge withinternal error, for any Worker, not only this plugin. A deployed Worker reaches both Zarinpal hosts normally. For local end-to-end tests use the Node.js setup above, or test on a deployed preview. - Cloudflare limits per plugin call: 10 subrequests and 50 ms CPU. Each route stays within them, which is why reconciliation handles 3 payments per run. With three checks per payment that covers about 60 abandoned payments an hour. Above that, checks queue up in order but are never dropped.
- Display name. EmDash shows config-installed plugins by their ID, so the admin says zarinpal.
- Registry. The plugin is not yet published to the EmDash registry. Install it from GitHub as shown above.
pnpm install
pnpm test # manifest validation + 36 tests through EmDash's Cloudflare sandbox runner
pnpm run typecheckMost tests use @emdash-cms/plugin-test, which loads the built plugin through EmDash's production Worker Loader wrapper with mocked Zarinpal responses. They cover idempotency and restarts, including a slow request racing its own retry, forged NOK callbacks, -51 followed by a later payment, gateway outages, merchant ID changes, orders with several attempts, rate limits, link payments and the admin pages.
End-to-end runs use Playwright against the real sandbox.zarinpal.com and a real EmDash site. The scripts are in e2e/:
cd e2e && npm install && npx playwright install chromium
node e2e.mjs ../docs/screenshots /path/to/site/.env # links, checkout, cancel, ledger, screenshots
node abandon.mjs ../docs/screenshots /path/to/site/.env # buyer pays and never returns; admin recoversThe screenshots in this README come from those runs.
pnpm run dev # rebuild on change
pnpm run build # writes dist/ (commit it: GitHub installs use it)Source layout:
| File | Role |
|---|---|
src/zarinpal.ts |
Zarinpal v4 client: request, verify, error codes |
src/payments.ts |
Settings, payment records, creation, verification, rate limit |
src/plugin.ts |
Public routes, cron hook |
src/admin.ts |
Block Kit admin pages and widget |
src/reconcile.ts |
Reconciliation job |
src/snippet.ts |
Payment-link button HTML |
emdash-plugin.jsonc |
Capabilities, allowed hosts, storage, settings schema |
Before publishing to the EmDash registry, replace publisher in emdash-plugin.jsonc with your own Atmosphere DID and run pnpm run publish.
این افزونه درگاه زرینپال را به سایتهای EmDash روی Cloudflare Workers یا Node.js اضافه میکند.
نصب روی Cloudflare
۱. در wrangler.jsonc اتصال worker_loaders با نام LOADER را فعال کنید (پلن پولی Workers لازم است).
۲. افزونه را نصب کنید:
pnpm add github:taleghanidev/emdash-plugin-zarinpal۳. در astro.config.mjs بنویسید sandboxRunner: sandbox() و sandboxed: [zarinpal].
۴. کلید رمزنگاری و در صورت نیاز کلید API را بسازید و با wrangler secret put ذخیره کنید.
۵. صفحههای نمونهٔ پوشهٔ examples/astro را در سایت کپی و سایت را مستقر کنید.
تنظیم
در پنل مدیریت به Plugins و سپس تنظیمات zarinpal بروید. مرچنتکد را وارد کنید. در حالت Sandbox هر UUID قابل قبول است. نشانی سایت را بنویسید؛ دامنه باید با دامنهٔ ثبتشده در زرینپال یکی باشد.
دریافت پول
- لینک پرداخت: در صفحهٔ Payment links مبلغ را تعیین کنید و کد دکمه را در صفحه بگذارید.
- پرداخت از سمت سرور: کد سایت شما مبلغ را تعیین میکند و مسیر
checkoutرا با کلید API صدا میزند.
نکتههای مهم
- پرداخت فقط وقتی «پرداختشده» میشود که متد verify زرینپال آن را تأیید کند. پارامتر
Status=OKدر نشانی بازگشت هیچوقت ملاک نیست. - اگر خریدار پرداخت کند و به سایت برنگردد، افزونه پرداخت را در پسزمینه دوباره بررسی میکند. قطعی ارتباط با زرینپال هیچ پرداختی را از بین نمیبرد. از صفحهٔ پرداختها هم میتوانید «Verify now» را بزنید.
- برای تحویل سفارش، وضعیت را از مسیر
paymentبا کلید API بپرسید و مبلغ و محیط (production) را بررسی کنید. - مبلغ تأیید باید با همان واحدی باشد که پرداخت با آن ساخته شده است (تومان یا ریال). افزونه این را خودکار رعایت میکند.
MIT © Mehdi Taleghani










{ "worker_loaders": [{ "binding": "LOADER" }] }