Pay with Elknz — Partner integration guide
Hand this file to any Elknz-owned app/project that should accept wallet payments.
You need from Superadmin (shown once on create/rotate):
| Secret | Where used |
|---|---|
API key (ewk_…) | Server → Elknz when creating/polling/canceling intents |
Webhook signing secret (ews_…) | Verify payment.succeeded webhooks |
Never put the API key in a browser or mobile client. Only your backend.
Hosts
| Env | API base | Buyer confirm UI |
|---|---|---|
| Production | https://api.elknz.com | https://elknz.com/{locale}/pay/confirm |
| Local | https://api.elknz.com or http://127.0.0.1:1233 | https://elknz.com/... (Caddy) or marketplace Next :3000 |
Confirm URLs are built from WALLET_PUBLIC_ORIGIN / MODE_A_CONFIRM_ORIGIN / MARKETPLACE_PUBLIC_ORIGIN (default `https://elknz.com`). A separate wallet.elknz.com app is optional and not required for Mode A.
All partner calls below are under:
{API_BASE}/api/wallet/v1/...Auth header on every partner call:
X-Elknz-Wallet-Key: ewk_YOUR_API_KEYEnd-to-end flow
Your app (server) Elknz API Buyer browser Your webhook
| | | |
|-- POST payment-intents -->| | |
|<-- intent_id + confirm_url | | |
|-- redirect buyer --------|----------------------->| confirm_url |
| |<----- JWT confirm -----| |
| |-- debit buyer / credit merchant wallet ------->|
| |---------------- POST payment.succeeded ------>|
|<-- buyer return_url ----|<----- redirect --------| |- Create a Payment Intent (server).
- Redirect the buyer to
confirm_urlfrom the response. - Buyer signs in on wallet (if needed) and confirms.
- Buyer returns to your
return_urlwith query params. - Trust the webhook (or poll GET intent) before fulfilling — not the return URL alone.
1) Create Payment Intent
POST /api/wallet/v1/payment-intents
X-Elknz-Wallet-Key: ewk_…
Content-Type: application/json
{
"amount": 12.50,
"currency": "USD",
"merchant_reference": "order_1001",
"return_url": "https://your-app.example/pay/return",
"webhook_url": "https://your-app.example/webhooks/elknz"
}| Field | Required | Notes |
|---|---|---|
amount | yes | Positive; capped by Elknz (MODE_A_MAX_AMOUNT, default 500) |
currency | no | Default USD |
merchant_reference | yes | Unique per partner — your order/invoice id (idempotent create) |
return_url | yes | http(s)://… — buyer lands here after confirm |
webhook_url | no | Overrides partner default webhook URL for this intent |
billing_mode | no | one_time (default) or recurring |
interval | if recurring | month or year |
Recurring create
{
"amount": 9.99,
"currency": "USD",
"merchant_reference": "plan_pro_user_42",
"return_url": "https://your-app.example/pay/return",
"webhook_url": "https://your-app.example/webhooks/elknz",
"billing_mode": "recurring",
"interval": "month"
}Response includes subscription_id (wsub_…) and billing_mode / interval. First charge still uses confirm_url like one-shot.
After confirm, Elknz auto-renews when period_end is due (wallet debit → merchant credit). Failed renew expires the subscription.
Partner cancel:
POST /api/wallet/v1/subscriptions/{subscription_id}/cancel
X-Elknz-Wallet-Key: ewk_…Poll:
GET /api/wallet/v1/subscriptions/{subscription_id}
X-Elknz-Wallet-Key: ewk_…Example response
{
"intent_id": "wpi_…",
"amount": 12.5,
"currency": "USD",
"merchant_reference": "order_1001",
"status": "requires_confirmation",
"return_url": "https://your-app.example/pay/return",
"billing_mode": "one_time",
"interval": null,
"subscription_id": null,
"expires_at": "2026-09-13T02:30:00",
"confirmed_at": null,
"created_at": "2026-09-13T02:00:00",
"buyer_user_id": null,
"partner_name": "My App",
"confirm_url": "https://elknz.com/ar/pay/confirm?intent=wpi_…"
}curl
curl -sS -X POST "$API_BASE/api/wallet/v1/payment-intents" \
-H "X-Elknz-Wallet-Key: $ELKNZ_WALLET_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 12.50,
"currency": "USD",
"merchant_reference": "order_1001",
"return_url": "https://your-app.example/pay/return",
"webhook_url": "https://your-app.example/webhooks/elknz"
}'Then redirect:
HTTP 302 Location: {confirm_url}(or window.location = confirm_url from your frontend after your backend returns it)
Intents expire (~30 minutes). Creating again with the same merchant_reference returns the existing intent if still open.
2) Return URL (browser)
After success, Elknz redirects to:
{return_url}?intent_id=wpi_…&status=succeededTreat this as UX only. Confirm payment via webhook or GET (below).
3) Poll status (optional)
GET /api/wallet/v1/payment-intents/{intent_id}
X-Elknz-Wallet-Key: ewk_…Statuses: requires_confirmation | succeeded | canceled | expired
4) Cancel (optional)
POST /api/wallet/v1/payment-intents/{intent_id}/cancel
X-Elknz-Wallet-Key: ewk_…Only if not already succeeded.
5) Webhook payment.succeeded
Elknz POSTs JSON to your webhook_url (or partner default).
Headers
| Header | Meaning |
|---|---|
Content-Type | application/json |
X-Elknz-Signature | sha256=<hex> — HMAC-SHA256 of raw request body using webhook signing secret |
X-Elknz-Event | payment.succeeded |
X-Elknz-Timestamp | Unix seconds |
User-Agent | ELKNZ-WalletModeA/1 |
Body
{
"event": "payment.succeeded",
"event_id": "payment.succeeded:wpi_…:2026-09-13T02:01:10.883694",
"status": "paid",
"payment_id": "wpi_…",
"amount": 12.5,
"currency": "USD",
"merchant_reference": "order_1001",
"billing_mode": "one_time",
"interval": null,
"subscription_id": null,
"partner_slug": "my-app",
"elknz_user_id": 42,
"elknz_username": "buyer1",
"paid_at": "2026-09-13T02:01:10.883694"
}Recurring first charge includes billing_mode: "recurring", interval, subscription_id, period_start / period_end.
Subscription lifecycle events
X-Elknz-Event | When |
|---|---|
subscription.renewed | Auto renew succeeded |
subscription.canceled | Partner canceled |
subscription.expired | Renew failed (insufficient balance etc.) |
Same HMAC headers; body uses subscription_id (no payment_id). Deduplicate on event_id.
- Respond 2xx quickly or Elknz retries (backoff).
- Deduplicate on
event_id(and/orpayment_id/merchant_reference/subscription_id). - Production webhook URLs must be HTTPS (local smoke allows
http://127.0.0.1/localhost/host.docker.internal).
Verify signature (Python)
import hashlib
import hmac
def verify_elknz_signature(*, secret: str, raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, (header or "").strip())Verify signature (Node.js)
const crypto = require("crypto");
function verifyElknzSignature(secret, rawBodyBuffer, header) {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBodyBuffer).digest("hex");
const provided = (header || "").trim();
return (
expected.length === provided.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
);
}Important: hash the raw bytes of the body (before JSON.parse). Do not re-serialize JSON and then verify.
Resend (ops)
POST /api/wallet/v1/payment-intents/{intent_id}/resend-webhook
X-Elknz-Wallet-Key: ewk_…Only for succeeded intents.
Money movement (what Elknz does)
On confirm:
- Debit buyer usable wallet.
- Credit the partner’s merchant marketplace user (
owner_user_idset in Superadmin) — withdrawable balance. - Fire webhook.
Your app does not move money; mark your order paid when the webhook verifies.
Checklist for a new Elknz app
- Superadmin → Wallet partners → create partner → assign merchant user → copy API key + webhook secret.
- Set env in the other project:
ELKNZ_WALLET_API_KEY,ELKNZ_WALLET_WEBHOOK_SECRET,ELKNZ_API_BASE. - Implement create intent + redirect to
confirm_url. - Implement HTTPS webhook with signature verify + idempotent fulfill.
- Optionally poll GET intent on return page as a fallback UX.
See also: Access Mode B when buyers pay on the ELKNZ Access catalog instead.