Peruri e-Meterai integration
Obscura affixes e-Meterai (Indonesian electronic stamp duty, Rp10.000) directly through
Peruri — the state issuer — behind the existing internal/esign/app.ExternalSealer port.
All Peruri protocol lives in one adapter (internal/esign/adapters/sealer_peruri.go);
the esign service, DMS, workflow, and HTTP handlers are unchanged.
Scope (deliberate):
- e-Meterai only. Peruri's certified e-Signature and e-Stamp (Segel) products are
separate contracts (no NDA yet). The adapter declares this via theKindSupporter
capability, so even withESIGN_PROVIDER=perurithe official-signing tier reports
unavailable (UI hides it) instead of erroring mid-flow;Seal(Kind=sign)additionally
returns a clearesign.peruri.sign_unsupportederror as belt-and-braces. - Single-document, fully synchronous. Peruri's only async API — batch serial-number
generation — needs a publicreturn_urlPeruri can call back, which conflicts with the
on-prem/air-gapped-first posture. It is intentionally not used; a polling-based batch mode
(Check Status BatchID+Get SN & QR — Batch) can be added later if bulk stamping is
ever needed. - No end-user redirect anywhere. The whole flow is server-to-server with a service
account: login → generate SN + QR → stamp → PDF. There is no signing ceremony, no OTP, no
e-KYC, and neither the requester nor any signer ever touches a Peruri page. (The
e-form.peruri.co.id/pdfviewerURL in Peruri's docs is a developer aid for picking QR
coordinates during integration — not part of the runtime flow.)
The flow (Dokumen Web Service E-Meterai V1.3)
login (JWT, 24h) ─► generate serial number + QR specimen ─► stamp ─► stamped PDF
backendservice stampv2 /chanel/stampv2 mode-specific (below)
Both modes share login, generate-SN, and the generic APIs; they differ only in where the
document goes for stamping:
PERURI_MODE=onprem (primary) |
PERURI_MODE=oncloud |
|
|---|---|---|
| Document location | never leaves our infra — only its hash reaches Peruri | uploaded to Peruri (≤10MB), stamped there, downloaded back |
| Stamping engine | Peruri's Sign Adapter container deployed next to Obscura | Peruri's cloud stampservice |
| Extra infra | Sign Adapter container + shared volume | none |
| Extra config | PERURI_ADAPTER_URL, PERURI_SHAREFOLDER |
none |
On-prem stamping is a file dance over the Sign Adapter's shared volume: the adapter writes
the PDF into UNSIGNED/ and the base64-decoded QR specimen into STAMP/, POSTs the local
/adapter/pdfsigning/rest/docSigningZ, reads the result from SIGNED/, and cleans all
three up. Obscura's paths (PERURI_SHAREFOLDER) and the adapter's fixed /sharefolder
namespace refer to the same directory via the container volume.
Configuration (env)
| Var | Meaning |
|---|---|
METERAI_PROVIDER=peruri |
select Peruri for e-Meterai only, independent of ESIGN_PROVIDER (empty = follow ESIGN_PROVIDER) — so Mekari/internal signing can pair with Peruri stamps |
PERURI_MODE |
onprem (default) or oncloud |
PERURI_ENV |
staging (default) or production — presets all *.e-meterai.co.id endpoints |
PERURI_EMAIL / PERURI_PASSWORD |
service-account credentials (request from PDS's account manager; registered via Peruri's POS portal) |
PERURI_ADAPTER_URL |
on-prem only: the deployed Sign Adapter base, e.g. http://signadapter:8080 |
PERURI_SHAREFOLDER |
on-prem only: where the adapter's /sharefolder volume is mounted in the Obscura container (must contain UNSIGNED/, STAMP/, SIGNED/) |
PERURI_DOC_TYPE |
default namadoc document-type code for generate-SN (default 2; list via Peruri's Jenis Document API) |
METERAI_QUOTA_WARN |
remaining-stamp threshold for the daily low-quota inbox warning (default 20; 0 disables) |
PERURI_BACKEND_URL / PERURI_STAMP_URL / PERURI_UPLOAD_URL / PERURI_STAMPSERVICE_URL |
individual endpoint overrides (rarely needed — PERURI_ENV presets them) |
Boot fails closed on an unusable Peruri config (missing credentials; on-prem without
adapter URL/sharefolder) instead of degrading silently. HTTP_PROXY/HTTPS_PROXY are
honored for restricted networks — the same knob Peruri's own Sign Adapter uses.
Deploying the Sign Adapter (on-prem)
In this repo's stack it is one command — the service ships profile-gated in
deploy/docker-compose.yml with the shared volume pre-wired into obscura:
docker compose --profile peruri up -d # + set METERAI_PROVIDER/PERURI_* via env file
Validated against the real adapter (v2.0.16) on 2026-07-02 with fake credentials:
- Its OpenAPI (
/docs) matches our payload field-for-field; notereasonis required
by the live schema although the 2022 PDF marks it optional (we always send it). - The adapter validates the PDF (
startxrefmust be present — error 83) and the QR PNG
(error 86) locally before any remote call, then reaches
stampservicestg.e-meterai.co.id— our probe got the remote "Token JWT invalid / expired"
(91), proving the full path works without a proxy on this network. - Error bodies can arrive with HTTP 500 (not 200): the adapter client parses JSON error
bodies on non-2xx responses so code-specific handling (token refresh, classification)
still runs. - Bonus discovery: the live adapter also exposes
/adapter/pdfsigning/rest/multiple/docSigningZ
(many stamps on one document in one call —stampPositionarray) which the 2022 docs don't
mention; relevant to the roadmapped batch feature.
For a standalone deployment (outside this compose stack), Peruri's Panduan Teknis
Deployment Modul Sign Adapter v3.1 (Nov 2024) prescribes:
# docker-compose.yml (alongside the obscura stack)
services:
signadapter:
image: registry.perurica.co.id/e-meterai/signadapter:2.0
container_name: signadapter
restart: always
ports: ["8080:7777"]
environment:
ENV: STAGING # or PRODUCTION
TZ: Asia/Jakarta
# HTTP_PROXY / HTTPS_PROXY if the site uses an egress proxy
volumes:
- /logs:/app/logs
- /sharefolder:/app/sharefolder # ← the SAME directory must be mounted into obscura
sudo mkdir -p /sharefolder/{UNSIGNED,STAMP,SIGNED} /logs
docker compose up -d
curl http://localhost:8080/ # → welcome to signadapter … (Swagger at /docs)
Then give the Obscura container the same volume and point it at the adapter:
# obscura service additions
environment:
METERAI_PROVIDER: peruri
PERURI_MODE: onprem
PERURI_ENV: staging
PERURI_EMAIL: ...
PERURI_PASSWORD: ...
PERURI_ADAPTER_URL: http://signadapter:8080
PERURI_SHAREFOLDER: /sharefolder
volumes:
- /sharefolder:/sharefolder
Note: Peruri's older access sheet references registry.perurica.co.id:443/keystamp/signadapter:latest
(pullable without login); the v3.1 deployment guide uses …/e-meterai/signadapter:2.0. Prefer
the versioned image.
Error handling
Peruri failure modes are mapped to stable kernel.Error codes:
| Situation (Peruri code) | Behavior |
|---|---|
JWT expired/invalid (01, 02/91 + "token") |
transparent re-login + single retry; token is cached with a 10-min early-refresh margin against its 24h expiry |
Quota exhausted (93) |
esign.peruri.quota_insufficient — actionable message ("top up via the Peruri POS portal"); never auto-retried (retrying cannot help and each SN costs quota) |
Pemungut account (90 + "pemungut") |
esign.peruri.pemungut_unsupported — the account class needs per-document tax-subject fields this integration does not send (see limitations) |
Signer profile / certificate errors (message-matched; WS codes 91/93/94) |
esign.peruri.profile — a Sign Adapter deployment problem to raise with PDS, not retried |
Malformed PDF (83, 85) / password-protected (82) |
esign.peruri.doc_malformed / doc_password — permanent, surfaced to the caller |
Oversize on-cloud upload (>10MB, 04) |
rejected locally before upload; message points at PERURI_MODE=onprem |
Serial already used / invalid (07, 97) |
esign.peruri.serial_conflict — permanent |
Transient stamping failures (84 write, 86/87 stamp, 92 image, 93 network, transport errors) |
re-stamped with Peruri's documented retryFlag: "1" (same serial — no quota burned), up to 3 attempts with backoff |
Sharefolder misconfiguration (81, write failures) |
esign.peruri.sharefolder/src_missing with an explicit hint that PERURI_SHAREFOLDER and the adapter's volume must be the same directory |
The claim-before-charge ledger (meterai_records) is unchanged: one stamp per document,
the claim is released on failure so a retry can re-claim.
Placement, quota visibility
- Stamp placement:
POST …/versions/{v}/meteraiaccepts an optional
{"placement": {page, llx, lly, urx, ury}}body (PDF points, bottom-left origin — the
same shape as the sign endpoint). The QR is square; providers square the rect to its
shorter side. Omitted = a 100×100 box near the bottom-left of page 1. The document UI
offers a visual picker. - Quota:
GET /api/v1/esign/meterai/quota(permissionmeterai.affix) reports
{provider, saldo}via Peruri's Check Saldo POS (the dev mock returns 42; Mekari
answers 409 — its balance lives in its own dashboard). A daily job
(esign.meterai_quota_warning) notifiesmeterai.affixholders when the balance drops
belowMETERAI_QUOTA_WARN(default 20; 0 disables).
Known limitations / open items
- Only regular (non-Pemungut) Peruri service accounts are supported. A Pemungut
(tax-collector) account makesnilaidoc/namejidentitas/noidentitas/namedipungut
mandatory on every generate-SN — per-document tax-subject data that a fixed env var cannot
honestly supply. Such accounts fail with a dedicatedesign.peruri.pemungut_unsupported
error; threading tax-subject fields through the affix API is a documented follow-up. - The on-cloud download URL is domain-pinned.
urlFileis fetched only from
*.e-meterai.co.id/*.peruri.co.id/*.perurica.co.id(or an explicitly configured
endpoint host), so a tampered response cannot redirect our Bearer token elsewhere. - "On-prem" is not air-gapped. Login, SN generation, and the Sign Adapter's own
hash-signing/TSA calls all need outbound HTTPS to*.e-meterai.co.id/
timestamp.peruri.co.id. Only the document stays local. - Rate limits are undocumented in Peruri's V1.3 API docs. The adapter uses conservative
90s timeouts and bounded retries; if Peruri publishes limits, revisit. - Coordinate origin needs one empirical check. Peruri's
visLLY/visURYexamples put the
larger Y on the "lower-left" corner (top-origin convention); the adapter flips our
bottom-origin PDF points accordingly and assumes A4 page height (like the Mekari adapter).
Verify placement on staging with a real credential, especially for non-A4 pages. - Signature coexistence must be validated empirically. Stamping uses
certificatelevel: NOT_CERTIFIED(an approval signature, not DocMDP-certifying), and the
on-prem adapter signs incrementally — but whether a prior PAdES signature survives
byte-wise must be tested against a signed document once staging credentials exist (the
Mekari e-meterai path broke signatures via provider re-save; seeMEKARI.md). - A failed stamp after a successful generate-SN leaves that serial unused (
NOTSTAMP).
The claim is released and a retry generates a new serial (quota cost). The unused serial
remains recoverable via Peruri'sCheck Daftar SN/Generate QR ImageAPIs; automatic
serial reuse across attempts is a possible follow-up. - Field-name quirks in Peruri's docs are handled defensively: generate-SN's QR field is
documented asfilenameQRbut returned asImagein the on-prem examples — the adapter
acceptsImage/image/base64/filenameQRand can re-fetch the QR by serial if absent. - Staging vs production behavior: the V1.3 web-service PDFs date from 2022 while the
Sign Adapter guide is v3.1 (Nov 2024); expect minor drift and validate the full flow on
staging first (PERURI_ENV=staging).
Testing
internal/esign/adapters/sealer_peruri_test.go pins the protocol against an in-process fake
Peruri (no network, no DB): token caching + refresh-on-expiry, both QR response shapes, the
on-prem sharefolder dance incl. cleanup, the on-cloud upload→stamp→download chain,
retryFlag re-stamping, quota/oversize/unsupported-kind error mapping, and the coordinate
flip. Run with:
go test ./internal/esign/adapters/ -run TestPeruri -count=1
(Do not run the package's unfiltered test suite on a dev box wired to the live demo DB.)
Verified against the official PDS docs (2026-07-07)
The NDA docs in docs/PERURI/ were cross-checked against the adapter:
- Sign-adapter env is correct. The official
env stg.txtlists 8 vars
(MY_TRUSTSTORE,MJ_HASH_URL,MJ_CERT*_URL,SIGNER_PROFILE_NAME,CRL_URL,
TSA_URL=https://tsa.e-meterai.co.id/signserver/tsa?workerName=TimeStampSigner1101), but
the v2.0.16serverbinary bakes the STAGING/PROD presets in keyed onENV— the
running adapter carries onlyENV=STAGINGand reachesstampservicestgcorrectly. Those
8 vars are the override surface (older versions / PROD / custom TSA); no change needed. - Error-Handling E-Meterai v1.0 alignment. Login RC99→120s retry, generate-SN/stamping
"01 Token Expired"→re-login,retryFlag:"1"re-stamping, 07/97 serial-invalid classification
are all implemented. Fixed: stamping RC 93 "Error While Signing" now re-authenticates
before retrying (§3.C) instead of a same-token retry; RC 93 "operation timed out" (§3.E) and
generate-SN quota-93 are disambiguated by message and unaffected.
Production-hardening gaps — CLOSED (2026-07-07, commits 06310d3 + 02b45c6)
The two money-edge behaviors flagged below were built once staging saldo was funded
(confirmed saldo:100 via a live, read-only probe — nothing burned to verify it):
- Generate-SN timeout reconciliation (§2.C / §2.F) — DONE.
generateSNattempts are
now bounded byperuriGenerateSNTimeout(30s, independent of the caller's context — the
doc: Peruri sets no server-side timeout, the client chooses its own RTO). On a genuine
timeout (not connect-refused, which never reached Peruri and is safely retried directly),
reconcileTimedOutGenerateSNconsults Check-Daftar-SN
(GET {stampURL}/chanel/sale/stamp/ext/{acct}?status=NOTSTAMP¬Encrypt=true&startDate=today&endDate=today)
rather than diffing a saldo snapshot — this adapter is single-document/synchronous, so any
NOTSTAMP entry found is the one from the timed-out call; reuse it (no new quota consumed).
None found → the call never created anything → generate fresh, safely. The{acct}path
segment isresult.data.login.user.userdetails[0].locations[0].id(a Mongo-style
ObjectId — not the JWTsub), now captured at login. Endpoint + extraction verified
live against staging (200,total:0, zero quota impact). - Failed-SN refund tracking (§3.B) — DONE. A permanent serial-consuming stamp failure
(07/97 invalid-or-used) is recorded (migration00090_peruri_failed_serials: serial,
reason, error code/message, timestamp) via an optionalFailedSerialRecorderport, and
the flow auto-continues once with a freshly generated serial ("Stamping dapat
dilanjutkan menggunakan SN baru"). Admin-visible:GET /api/v1/admin/esign/peruri/failed-serials
+ a read-only table in Admin → Licensing (below the quota card, hidden when empty).