TypeScript client for the SATIM payment gateway (CIB and Edahabia cards, Algeria). Zero runtime dependencies. Web-standard globals only. Strict TypeScript.
Warning
Never exercised against the live SATIM production gateway. Validated only against
the test gateway (test2.satim.dz) and simulated responses. Production use is at
your own risk; the authors accept no liability for payment failures, data loss, or
financial loss. Run your own end to end test pass against the test gateway first.
In scope:
- Order registration (
/register.do,/registerPreAuth.do) and hosted form redirect. - Order lifecycle: acknowledge, status, extended status, deposit, refund, reverse, decline.
- Terminal capability probing.
- Zero trust callback handling (signature verification plus live state re-fetch).
Out of scope (not exposed by the SATIM deployment; see docs/ENDPOINTS.md):
- Card bindings and tokenized payments.
- Wallet payments (Apple, Google, Samsung).
- Recurring and installment payments.
- 3-D Secure step handling. The hosted form owns it.
Required globals: fetch, AbortController, URL, URLSearchParams, TextEncoder,
TextDecoder, structuredClone, crypto.getRandomValues, Response.
No node: imports. No polyfill or compatibility flag needed on any runtime below.
| Runtime | Minimum | Notes |
|---|---|---|
| Node.js | 20 | type: module package, ESM only |
| Bun | 1.0 | |
| Deno | 1.28 | |
| Cloudflare Workers | any | nodejs_compat NOT required |
| Vercel / Netlify Edge | any |
npm install satim-sdk
bun add satim-sdk
pnpm add satim-sdkimport { Satim } from 'satim-sdk';
const satim = new Satim({
username: process.env.SATIM_USERNAME!,
password: process.env.SATIM_PASSWORD!,
terminalId: process.env.SATIM_TERMINAL_ID!,
});
const payment = await satim
.amount(1500) // major units, whole dinars, >= 50
.returnUrl('https://your-app.com/callback')
.register();
return payment.redirectResponse(); // Web API Response, HTTP 302Verification is server side and mandatory. Redirect query parameters are not proof of payment.
const response = await satim.confirm(orderId, expectedCartTotal);
if (response.isSuccessful()) { /* amount already verified, safe to fulfil */ }
else if (response.isPending()) { /* still in flight, do not fulfil, poll later */ }
else if (response.isPreAuthorized()) { /* funds held, call deposit() to capture */ }
else { /* response.getErrorMessage() */ }Base URL: https://cib.satim.dz/payment/rest. With setTestMode(true):
https://test2.satim.dz/payment/rest. Every call is a form encoded POST carrying
userName and password.
| Method | Endpoint | Mutating | Retried on transient failure | Returns |
|---|---|---|---|---|
register() |
/register.do |
yes | only when an idempotency key is set | RegisterResponse |
registerPreAuth() |
/registerPreAuth.do |
yes | only when an idempotency key is set | RegisterResponse |
safeRegister(ref) |
/register.do |
yes | yes (key derived from ref) |
RegisterResponse |
safeRegisterPreAuth(ref) |
/registerPreAuth.do |
yes | yes (key derived from ref) |
RegisterResponse |
confirm(orderId, expected) |
/public/acknowledgeTransaction.do |
yes | no | ConfirmResponse |
status(orderId) |
/getOrderStatus.do |
no | yes | ConfirmResponse |
statusExtended(orderId) |
/getOrderStatusExtended.do |
no | yes | ConfirmResponse |
statusAll(orderIds[]) |
/getOrderStatus.do per id |
no | yes | ConfirmResponse[] |
deposit(orderId, amount?) |
/deposit.do |
yes | no | ConfirmResponse |
refund(orderId, amount) |
/refund.do |
yes | no | ConfirmResponse |
reverseOrder(orderId) |
/reverse.do |
yes | no | ConfirmResponse |
decline(orderId, orderNumber) |
/decline.do |
yes | no | ConfirmResponse |
checkCapabilities() |
probes 6 endpoints | no | no | SatimCapabilities |
warmup() |
/getOrderStatus.do |
no | no | void, never throws |
createWebhookHandler(opts) |
local | no | n/a | WebhookHandler |
Semantics that are not obvious from the signature:
confirm()callsverifyAmount(expected)on a successful response. There is no opt out. It also rejects a settledcurrencythat differs from the configured one.confirm()acknowledges the transaction. It is a mutation, never retried, never used on a replayed callback. Usestatus()to re-read.status()does NOT verify the amount. CallverifyAmount()yourself before acting on it.deposit(orderId)with no amount sendsamount=0, which BPC reads as "capture the full order".decline()cancels an order that was never paid, and requires the order number, not just the id.safeRegister(ref)derives both the idempotency key and the order number fromref. A gateway duplicate verdict (ErrorCode 1) is translated toSatimDuplicateOrderError; recover withstatus()on the original order id.
SatimConfig is immutable. Every setter clones the instance, mutates the clone, and
returns it. A base instance is safe to share across concurrent requests.
const base = new Satim(creds).setTestMode(true).language('FR');
const a = base.amount(1500).returnUrl(urlA); // base is unchanged
const b = base.amount(9900).returnUrl(urlB); // a is unchanged| Setter | Wire field | Constraint, violation throws SatimInvalidArgumentError |
|---|---|---|
amount(n) |
amount |
number, finite, > 0, <= 9999999999.99, <= 2 decimals, >= 5000 centimes (50 DZD), multiple of 100 centimes |
returnUrl(url) |
returnUrl |
http/https, passes the SSRF guard |
failUrl(url) |
failUrl |
http/https, passes the SSRF guard. Defaults to returnUrl |
description(text) |
description |
string, <= 600 chars, no < or > |
language(lang) |
language |
"FR", "AR", "EN". Default "FR" |
currency(code) |
currency |
"DZD" (012), "USD" (840), "EUR" (978). Default DZD |
orderNumber(n) |
orderNumber |
1 to 10 alphanumeric chars (SATIM AN.10). Default: random 10 char base36 |
timeout(seconds) |
sessionTimeoutSecs |
integer, 600 to 86400 |
userDefinedField(k, v) |
jsonParams |
key non empty, non numeric, <= 128 chars, not reserved; value string <= 20 chars |
userDefinedFields(obj) |
jsonParams |
applies userDefinedField per entry |
dynamicCallbackUrl(url) |
dynamicCallbackUrl |
http/https, passes the SSRF guard |
idempotencyKey(key) |
externalRequestId |
1 to 128 chars of [A-Za-z0-9_-] |
setTestMode(bool) |
n/a | rebuilds the HTTP client unless a custom one was injected |
allowPrivateUrls(bool) |
n/a | development only, see section 14 |
Reserved jsonParams keys, rejected at setter time: force_terminal_id, __proto__,
constructor, prototype.
Credential constraints, enforced in the Satim constructor: all three fields are strings,
trimmed non empty; username and password <= 100 chars; terminalId <= 16 chars.
Credentials cannot be re-set on an instance.
register() and registerPreAuth() additionally require returnUrl and amount.
Missing either throws SatimMissingDataError.
| Method | Returns | Notes |
|---|---|---|
getOrderId() |
string |
gateway order id |
getUrl() |
string |
hosted form URL. Enforces HTTPS and a hostname in {satim.dz, cib.satim.dz, test.satim.dz, test2.satim.dz} |
redirectResponse() |
Response |
HTTP 302 to getUrl(), same allowlist |
getRawResponse() |
RegisterOrderResponse |
shallow copy, no PII fields exist on this payload |
| Method | Returns | Notes |
|---|---|---|
getAmount() |
number | undefined |
major units. undefined if non integer minor units or above Number.MAX_SAFE_INTEGER |
getDepositAmount() |
number | undefined |
same rules, captured amount |
getOrderNumber() |
string | undefined |
OrderNumber then orderNumber |
getApprovalCode() |
string | undefined |
issuer approval code |
getCardPan() |
string | undefined |
masked PAN |
getCardHolderName() |
string | undefined |
|
getCardExpiry() |
string | undefined |
YYYYMM |
getIpAddress() |
string | undefined |
cardholder IP |
getSuccessMessage() |
string |
falls back to getErrorMessage() when not successful |
getErrorMessage() |
string |
|
verifyAmount(expected) |
void |
throws SatimUnexpectedResponseError on mismatch |
getRawResponse() |
Record<string, unknown> |
shallow copy with Ip, Pan, cardholderName, expiration replaced by "[REDACTED]" |
Exactly one of the ten predicates returns true for any well formed response.
| Predicate | Condition |
|---|---|
isPending() |
OrderStatus in {0 registered, 5 3-D Secure running, 7 pending payment} |
isPreAuthorized() |
OrderStatus == 1, funds held, awaiting deposit() |
isSuccessful() |
OrderStatus == 2, authorized and captured |
isReversed() |
OrderStatus == 3, authorization voided |
isRefunded() |
OrderStatus == 4 |
isRejected() |
OrderStatus == 6, or no known status plus a decline signal (actionCode 2003 or 111, params.respCode not in {"", "00"}, or an English "payment is declined" message) |
isPartiallyCaptured() |
OrderStatus == 8, multi part capture in progress |
isExpired() |
no known status, actionCode == -2007 |
isCancelled() |
no known status, not expired, error signal present, actionCode == 10 or an English "payment is cancelled" message |
isFailed() |
catch all: no known status and none of the composites matched |
isPending(), isPreAuthorized() and isPartiallyCaptured() are in flight states.
Treating them as failure invites a merchant to cancel or re-charge a live order.
All gateway amounts are integer minor units (centimes). The SDK converts through one
function, toMinorUnits, and never by ad hoc float arithmetic.
| Rule | Value |
|---|---|
| Conversion | Math.round(amount * 100) behind a precision guard |
| Precision guard | reject if abs(amount*100 - round(amount*100)) > max(1e-7, abs(round(amount*100)) * 1e-13) |
| Upper bound | MAX_SAFE_AMOUNT = 9999999999.99 |
| Lower bound | result must be >= 1 minor unit |
| Registration floor | 5000 minor units (50 DZD), SATIM rule |
| Registration granularity | multiple of 100 minor units (whole dinars), SATIM rule |
| Reverse conversion | parseFloat((minor / 100).toFixed(2)), undefined above Number.MAX_SAFE_INTEGER |
| Gateway amount parsing | accepts "5000" and "5000.00", rejects genuinely fractional minor units |
refund() and deposit() use the general rule (>= 1 minor unit); the 50 DZD floor and
whole dinar granularity apply to registration only.
const res = await satim.amount(1500).returnUrl(url).safeRegister('cart-42');deriveIdempotencyKey({merchantRef, amount, currency, mode})returnsdk_<sha256 hex>overmode|merchantRef|minorUnits|currency. Sent asexternalRequestId.deriveOrderNumber(merchantRef, currency, mode)returns 10 base36 chars overordnum|mode|merchantRef|currency. Space is 36^10; the 50 percent birthday point is near 60 million derived references.- A change of amount or currency changes the key, so a re-priced cart is a new order.
- Registration is retried on transient failure only when a key is present, because the gateway then deduplicates instead of creating a second order.
SATIM delivers notifications as query parameters on dynamicCallbackUrl, keyed
mdOrder (not orderId). Payloads are treated as untrusted: the handler uses them only
to learn which order to re-read, then fetches authoritative state from the gateway.
const handler = satim.createWebhookHandler({
// Exactly one of these two is required; constructing with neither throws.
callbackSecret: process.env.SATIM_CALLBACK_SECRET,
// allowUnverifiedCallbacks: true,
onResolveAmount: (orderId) => db.orders.findByGatewayId(orderId)?.totalDZD,
// Multi instance deployments MUST back these with a shared atomic store.
onCheckDuplicate: (orderId) => redis.exists(`satim:done:${orderId}`),
onMarkProcessed: (orderId) => redis.set(`satim:done:${orderId}`, '1'),
});
app.post('/satim/callback', async (req, res) => {
const outcome = await handler.inspect(req.query);
if (!outcome.verified) {
// Status selection matters: 200 tells SATIM the callback was handled and it
// will never redeliver, which silently drops a real payment notification.
return res.sendStatus({
invalid_source: 400,
bad_signature: 400,
rate_limited: 429,
unknown_order: 404,
}[outcome.reason]);
}
const { orderId, response, duplicate } = outcome.result;
if (response.isSuccessful() && !duplicate) await fulfilOrder(orderId);
res.sendStatus(200);
});| Option | Default | Constraint |
|---|---|---|
onResolveAmount |
required | returns expected major units, or nullish for an unknown order |
callbackSecret |
none | required unless allowUnverifiedCallbacks: true |
allowUnverifiedCallbacks |
false |
required unless callbackSecret is set |
onCheckDuplicate |
in memory Set |
must be atomic with the mark step across instances |
onMarkProcessed |
in memory Set, bounded at 10000 entries |
|
maxCallbacksPerWindow |
100 | integer >= 1, per handler instance, counts all orders |
rateLimitWindowMs |
60000 | integer >= 1000 |
suppressMultiInstanceWarning |
false |
silences the in memory fallback warning |
inspect(source)returns{verified: true, result}or{verified: false, reason}wherereasonisinvalid_source,bad_signature,rate_limited, orunknown_order.verify(source)is the lossy form: it collapses all four rejections tonull. Preferinspect()wherever the HTTP status matters.- An order is marked processed only on a terminal state: captured (2), refunded (4),
reversed (3). Pre-authorized holds, declines and expiries stay unmarked, so a later
capture or a successful card retry still arrives with
duplicate: false. - A signature proves origin, not freshness. A replayed notification carries a valid one. Both checks run; neither replaces the other.
BPC order management operations are enabled per merchant. deposit, refund, reverse
and decline may be deployed on the gateway and still closed to your terminal.
const caps = await satim.checkCapabilities();
// { credentialsValid: true,
// operations: { status: 'available', statusExtended: 'available',
// deposit: 'available', refund: 'not_permitted',
// reverse: 'not_permitted', decline: 'available' } }Each operation is probed with an order id that cannot exist, so nothing is mutated; the
gateway can only answer with a permission verdict. If the control probe shows the
credentials are rejected, credentialsValid is false and every operation reads
unknown, because a bad password denies everything and proves nothing about entitlement.
Run at startup or as a deployment smoke test. Six requests per call; not for the request path.
Every error extends SatimError.
| Class | Raised when | Extra fields |
|---|---|---|
SatimMissingDataError |
required field not set (returnUrl, amount, onResolveAmount, callback secret choice) |
|
SatimInvalidArgumentError |
validation failure at a setter, or gateway ErrorCode 6 (invalid order id) |
|
SatimInvalidCredentialsError |
HTTP 401/403, or gateway ErrorCode 5. On a permission gated endpoint the message says the terminal may simply not be entitled |
|
SatimGatewayError |
gateway ErrorCode 1, 3, 4, 7 |
errorCode, errorMessage |
SatimDuplicateOrderError |
safeRegister hit ErrorCode 1 |
merchantRef (sanitized) |
SatimUnexpectedResponseError |
transport, parse, HTTP, circuit, or unclassified gateway failure | errorCategory, isTimeout, httpStatus, gatewayErrorCode, gatewayErrorMessage |
ErrorCode mapping: 0 none, 1 duplicate order, 3 unknown currency, 4 missing
parameter, 5 access denied, 6 invalid order id, 7 system error, anything else
becomes SatimUnexpectedResponseError with errorCategory: "gateway".
SatimErrorCategory is "network" | "timeout" | "parse" | "http" | "gateway" | "circuit_open" | "unknown". Gateway messages are stripped of non printable bytes and
truncated to 200 characters before they reach any error message.
new Satim(credentials, options) accepts HttpClientOptions or an HttpClientService instance.
| Option | Default | Constraint |
|---|---|---|
maxRetries |
2 | clamped to 0 to 10 |
timeoutMs |
30000 | 1000 to 300000, per attempt |
circuitBreaker |
{failureThreshold: 5, resetTimeoutMs: 30000} |
false disables it |
fetch |
globalThis.fetch |
custom implementation, for example an undici Pool |
baseUrl |
gateway URL for the current mode | must be HTTPS unless the host is private |
Behaviour:
- Backoff is
500ms * 2^attemptplus 0 to 50 percent jitter. Maximum total backoff at defaults is about 2.25 seconds. - Retried: timeouts, connection level failures, HTTP 5xx. Never retried: HTTP 4xx, malformed payloads, an open circuit.
- Concurrent identical retryable requests are coalesced on a key of
endpoint + sha256(form body). Non retryable calls are never coalesced. - Circuit breaker states are CLOSED, OPEN, HALF_OPEN. Five consecutive transient failures open it; after 30 seconds one probe is admitted; success closes it, failure re-opens it with a fresh timer. A probe whose outcome is never reported is treated as abandoned after the reset timeout.
- 4xx responses do not count toward opening the circuit. A local
NODE_TLS_REJECT_UNAUTHORIZED=0fault is checked before the breaker gate and counts as neither failure nor probe. - Response bodies are streamed with a hard 1 MiB cap.
- Every request carries
Cache-Control: no-store, no-cacheandPragma: no-cache.
| ID | Invariant |
|---|---|
| S1 | Credentials live in a module private WeakMap. They are not own properties and never appear in Object.keys, JSON.stringify, console.log, or prototype traversal. Serializers return [REDACTED] |
| S2 | force_terminal_id is stripped from caller supplied jsonParams and re-injected from the credential store on every registration |
| S3 | All caller URLs are checked against private and reserved IPv4/IPv6 ranges, known internal hostnames, and non standard IP encodings (decimal, octal, hex) |
| S4 | The hosted form URL is accepted only over HTTPS and only for a known satim.dz hostname, on both getUrl() and redirectResponse() |
| S5 | Mutating calls (confirm, deposit, refund, reverseOrder, decline) are never retried |
| S6 | confirm() verifies the settled amount and currency on success, with no opt out |
| S7 | Requests are refused outright when NODE_TLS_REJECT_UNAUTHORIZED=0 is set |
| S8 | Callback checksums are verified with constant time comparison before any gateway call |
| S9 | Callbacks with duplicate query keys are rejected, closing the parser differential replay |
| S10 | PII (Ip, Pan, cardholderName, expiration) is redacted in ConfirmResponse.getRawResponse() |
Operator requirements:
- Verify server side. Redirect parameters are attacker controlled.
- Never log raw request or response bodies. Every POST body carries the merchant password.
- Load credentials from the environment or a secret manager, never from source.
- Keep TLS verification on. Never set
NODE_TLS_REJECT_UNAUTHORIZED=0. - In multi instance deployments, make the webhook duplicate check and mark atomic.
allowPrivateUrls(true) disables the private range checks for that instance only. It
still rejects obfuscated IP encodings, and validated private URLs are never cached.
It is a development affordance; enabling it in production re-opens the SSRF surface.
Full threat model and residual risks: SECURITY.md.
bun install # dev dependencies
bun test # unit suite (vitest)
npm run typecheck # strict tsc, no emit
npm run build # compile to dist/
npm run smoke # load dist/ in plain Node, assert the public surface
npm run docs # TypeDoc into docs/api/| Document | Content |
|---|---|
| ARCHITECTURE.md | layering, request lifecycle, invariants, state machines |
| SECURITY.md | threat model, controls, known limitations |
| docs/ENDPOINTS.md | probed endpoint inventory for the SATIM deployment |
| src/README.md | module map, dependency order, blast radius |
| src/responses/README.md | response wrappers, predicate contract |
| src/webhook/README.md | callback verification flow |
| CONTRIBUTING.md | rules for changes |
| CHANGELOG.md | released and unreleased changes |
MIT. See LICENSE.md.