TypeScript client for the SATIM payment gateway (CIB and Edahabia cards, Algeria). Zero runtime dependencies. Web-standard globals only. Strict TypeScript.
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:
/register.do, /registerPreAuth.do) and hosted form redirect.Out of scope (not exposed by the SATIM deployment; see docs/ENDPOINTS.md):
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-sdk
import { 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 302
Verification 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() calls verifyAmount(expected) on a successful response. There is no opt out.
It also rejects a settled currency that differs from the configured one.confirm() acknowledges the transaction. It is a mutation, never retried, never
used on a replayed callback. Use status() to re-read.status() does NOT verify the amount. Call verifyAmount() yourself before acting on it.deposit(orderId) with no amount sends amount=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 from ref.
A gateway duplicate verdict (ErrorCode 1) is translated to SatimDuplicateOrderError;
recover with status() 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}) returns
dk_<sha256 hex> over mode|merchantRef|minorUnits|currency. Sent as externalRequestId.deriveOrderNumber(merchantRef, currency, mode) returns 10 base36 chars over
ordnum|mode|merchantRef|currency. Space is 36^10; the 50 percent birthday point is
near 60 million derived references.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} where
reason is invalid_source, bad_signature, rate_limited, or unknown_order.verify(source) is the lossy form: it collapses all four rejections to null.
Prefer inspect() wherever the HTTP status matters.duplicate: false.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:
500ms * 2^attempt plus 0 to 50 percent jitter. Maximum total backoff at
defaults is about 2.25 seconds.endpoint + sha256(form body). Non retryable calls are never coalesced.NODE_TLS_REJECT_UNAUTHORIZED=0
fault is checked before the breaker gate and counts as neither failure nor probe.Cache-Control: no-store, no-cache and Pragma: 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:
NODE_TLS_REJECT_UNAUTHORIZED=0.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.