think
16px
820px

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 the KindSupporter
    capability, so even with ESIGN_PROVIDER=peruri the official-signing tier reports
    unavailable (UI hides it) instead of erroring mid-flow; Seal(Kind=sign) additionally
    returns a clear esign.peruri.sign_unsupported error as belt-and-braces.
  • Single-document, fully synchronous. Peruri's only async API — batch serial-number
    generation — needs a public return_url Peruri 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/pdfviewer URL 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; note reason is required
    by the live schema although the 2022 PDF marks it optional (we always send it).
  • The adapter validates the PDF (startxref must 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 — stampPosition array) 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}/meterai accepts 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 (permission meterai.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) notifies meterai.affix holders when the balance drops
    below METERAI_QUOTA_WARN (default 20; 0 disables).

Known limitations / open items

  • Only regular (non-Pemungut) Peruri service accounts are supported. A Pemungut
    (tax-collector) account makes nilaidoc/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 dedicated esign.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. urlFile is 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/visURY examples 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; see MEKARI.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's Check Daftar SN/Generate QR Image APIs; 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 as filenameQR but returned as Image in the on-prem examples — the adapter
    accepts Image/image/base64/filenameQR and 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.txt lists 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.16 server binary bakes the STAGING/PROD presets in keyed on ENV — the
    running adapter carries only ENV=STAGING and reaches stampservicestg correctly. 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):

  1. Generate-SN timeout reconciliation (§2.C / §2.F) — DONE. generateSN attempts are
    now bounded by peruriGenerateSNTimeout (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),
    reconcileTimedOutGenerateSN consults Check-Daftar-SN
    (GET {stampURL}/chanel/sale/stamp/ext/{acct}?status=NOTSTAMP&notEncrypt=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 is result.data.login.user.userdetails[0].locations[0].id (a Mongo-style
    ObjectId — not the JWT sub), now captured at login. Endpoint + extraction verified
    live against staging (200, total:0, zero quota impact).
  2. Failed-SN refund tracking (§3.B) — DONE. A permanent serial-consuming stamp failure
    (07/97 invalid-or-used) is recorded (migration 00090_peruri_failed_serials: serial,
    reason, error code/message, timestamp) via an optional FailedSerialRecorder port, 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).