Integration guide
End-to-end machine integration with /api/v1: get credentials, exchange them for a token, sign every request, and handle retries safely.
1. Obtain credentials
There is no insurer portal and no self-service credential creation. Your organization requests API access from LTFRB; an LTFRB administrator creates the API client on your behalf (POST /admin/v1/api-clients) and delivers you, through a secure out-of-band channel, a client_id, a client_secret (used only for token exchange) and a signing_secret (used only for HMAC signing). These are shown once, at creation, and cannot be retrieved again — only rotated (also an LTFRB action). Store all three in your own secrets manager the moment you receive them. Rotation keeps the old secret valid for 7 days so you can roll deployments without downtime; secrets expire after 365 days with warnings at 30 and 7 days. See sandbox onboarding for the request process and to test against staging before requesting production credentials.
2. Exchange credentials for a token
Tokens are opaque, short-lived (15 minutes) and scoped. Request one with grant_type=client_credentials:
POST /api/v1/oauth/token HTTP/1.1
Host: api.example.gov.ph
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=<CLIENT_ID>&client_secret=<CLIENT_SECRET>{
"success": true,
"data": {
"access_token": "8f2b6a...redacted...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "ppai:transmit ppai:read"
},
"error": null
}Cache the token, don't fetch one per request
expires_in (900) seconds. Reuse it until shortly before it expires — the token endpoint itself is rate-limited to 20 requests per minute per IP.3. Sign every /api/v1/ppai/* request
Every call to a PPAI endpoint (not the token endpoint) must carry four headers:
| Header | Value |
|---|---|
| X-PPAI-Client-Id | Your client_id. |
| X-PPAI-Timestamp | Unix seconds, must be within ±300s of server time. |
| X-PPAI-Nonce | 16-64 char random string, unique per client for 10 minutes. |
| X-PPAI-Signature | hex HMAC-SHA256 of the string-to-sign, using your signing_secret. |
The string-to-sign is five fields joined by a newline (exact byte layout, no trailing newline):
stringToSign = METHOD + "\n" + PATH_WITH_QUERY + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + hex(sha256(BODY))
signature = hex(HMAC-SHA256(signingSecret, stringToSign))METHODis uppercase (POST,GET).PATH_WITH_QUERYstarts with/api/v1/...and includes the query string exactly as sent, e.g./api/v1/ppai/policies/search?plateNumber=NGP1234.BODYis the exact bytes of the request body (empty string for GET). Build the JSON string once and reuse those exact bytes for both hashing and sending — re-serializing can reorder keys or change whitespace and break the signature.
The API compares signatures in constant time and rejects on any mismatch, an out-of-range timestamp, or a reused nonce.
Self-check your implementation against this test vector
Given (packages/shared/src/domain/signing.test.ts):
method = "POST"
pathWithQuery = "/api/v1/ppai/policies"
timestamp = "1789626318"
nonce = "n-0123456789abcdef"
body = {"a":1}
signingSecret = "secret"
stringToSign (5 lines joined by \n):
POST
/api/v1/ppai/policies
1789626318
n-0123456789abcdef
015abd7f5cc57a2dd94b7590f04ad8084273905ee33ec5cebeae62276a97f862
X-PPAI-Signature = 14ec9797f32c70abb8b47f05a86ed03d0f7a8cec7a2845df315fb1ffa3cb8a45
Feed this exact input into your implementation before pointing it at real credentials —
if you get the same signature, your signing code is correct.4. Working code samples
All five samples below sign and send the same example transmission. They were checked against the test vector above:
- bash + openssl and TypeScript (Node's
node:crypto, run on both Bun and Node) were executed against the test vector in this environment and reproduced14ec9797f32c70abb8b47f05a86ed03d0f7a8cec7a2845df315fb1ffa3cb8a45exactly. - Python uses the same primitives (
hashlib.sha256,hmac.new(..., hashlib.sha256)) as the verified bash sample; no Python interpreter was available in this environment to execute it directly, so verify it against the test vector before pointing it at real credentials. - Java and C# mirror the identical algorithm using their respective standard libraries (
MessageDigest/Mac,SHA256/HMACSHA256) and were reviewed but not executed (no JDK or .NET SDK in this environment) — also verify against the test vector first.
#!/usr/bin/env bash
# Verified: this exact construction (multi-line STRING_TO_SIGN + openssl dgst -hex)
# reproduces the signing.test.ts vector's signature byte-for-byte.
set -euo pipefail
API_BASE="https://api.example.gov.ph"
CLIENT_ID="00000000-0000-0000-0000-000000000000"
CLIENT_SECRET="replace-with-client-secret" # returned once when the API client was created
SIGNING_SECRET="replace-with-signing-secret" # returned once when the API client was created
# 1) Exchange client credentials for a 15-minute bearer token.
TOKEN_RESPONSE=$(curl -sS -X POST "$API_BASE/api/v1/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=$CLIENT_ID" \
--data-urlencode "client_secret=$CLIENT_SECRET")
ACCESS_TOKEN=$(echo "$TOKEN_RESPONSE" | grep -o '"access_token":"[^"]*"' | cut -d'"' -f4)
# 2) Build the exact bytes that get signed.
METHOD="POST"
PATH_WITH_QUERY="/api/v1/ppai/policies"
BODY='{"externalTransactionId": "INSUR-20260917-000123","issuingInsurerCode": "PAMI","policy": {"policyNumber": "POL-2026-00981","issuanceDate": "2026-09-16","coverageType": "PPAI_STANDARD"},"certificate": {"cocNumber": "COC-2026-00981","effectiveDate": "2026-09-17","expirationDate": "2027-09-16"},"operator": {"name": "Juan Dela Cruz","operatorType": "INDIVIDUAL","region": "NCR"},"vehicles": [{"plateNumber": "NGP1234","mvFileNumber": "123456789012345","engineNumber": "ENG123456","chassisNumber": "CHS1234567890","vehicleType": "JEEPNEY","denomination": "PUJ","passengerCapacity": 16,"region": "NCR"}]}'
TIMESTAMP=$(date +%s)
NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | sed 's/^.* //')
# stringToSign = METHOD \n PATH_WITH_QUERY \n TIMESTAMP \n NONCE \n hex(sha256(body))
STRING_TO_SIGN="$METHOD
$PATH_WITH_QUERY
$TIMESTAMP
$NONCE
$BODY_HASH"
SIGNATURE=$(printf '%s' "$STRING_TO_SIGN" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" -hex | sed 's/^.* //')
# 3) Send the signed, authenticated request.
curl -sS -X POST "$API_BASE$PATH_WITH_QUERY" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "X-PPAI-Client-Id: $CLIENT_ID" \
-H "X-PPAI-Timestamp: $TIMESTAMP" \
-H "X-PPAI-Nonce: $NONCE" \
-H "X-PPAI-Signature: $SIGNATURE" \
--data "$BODY"5. Idempotency & duplicates
Every transmission, amendment and cancellation requires a unique externalTransactionId (8-100 chars, [A-Za-z0-9._:-]). The server upserts on (transmitting_org_id, external_transaction_id):
- Same key, identical payload hash → the original acknowledgment is replayed (
replayed: true, HTTP 200), no new certificate is created. - Same key, still processing →
409 TRANSMISSION_IN_PROGRESS. - Same key, different payload →
409 IDEMPOTENCY_KEY_REUSED.
Use one idempotency key per logical business event (e.g. per certificate issued), and reuse the exact same JSON body when retrying.
A vehicle can only be insured once at a time — across all insurers
This is a different kind of duplicate than the idempotency key above: a vehicle may have only one in-force PPAI coverage at any time, checked across every insurer on the platform, not just yours (spec §3.4, 2026-09-17 amendment). Any of the vehicle's identifiers matching — plate, MV file, engine or chassis, including historical ones — counts as a match. A transmission (or an amendment that changes dates or vehicles) that would overlap another insurer's current coverage on that vehicle is rejected, not flagged.
- Top-level
error.code:VEHICLE_ALREADY_INSURED, HTTP 422, message "Vehicle is already insured." - One
error.details[]entry per affected vehicle,code:VEHICLE_ALREADY_INSURED, e.g.{ field: "vehicles[0].plateNumber", code: "VEHICLE_ALREADY_INSURED", message: "Vehicle ABC 1234 is already insured by PAMI until 2027-03-01." }— note that no competitor policy or COC number, or any operator data, is disclosed.
Cancelled certificates never block a new transmission, and a renewal that starts after the existing certificate's expiration date is allowed. This check runs under the same identifier locks used for vehicle resolution, so two insurers racing to transmit the same vehicle can't both win. See status codes & errors.
6. Retries
Safe to retry with the same idempotency key and body: network timeouts, 429 RATE_LIMITED (honor Retry-After), 500 INTERNAL_ERROR, and 409 TRANSMISSION_IN_PROGRESS (back off and retry). Use exponential backoff with jitter. Never change the payload or generate a new idempotency key on a retry of the same event — that would create a duplicate submission with a different key.
7. Amendments
Amend mutable fields with a fresh externalTransactionId and a reason (10-500 chars). Omitted sections keep their current values; identity fields (issuingInsurerCode, policy.policyNumber, certificate.cocNumber) are immutable — changing them returns 422 IMMUTABLE_FIELD (cancel and re-transmit instead). A successful amendment creates version N+1 and re-runs full validation.
Policy-level amendment rule: POLICY_SHARED_BY_OTHER_CERTIFICATES
Live now. Rejects an amendment (HTTP 422) that changes a policy-level field (changes.policy.issuanceDate, coverageType, premiumAmount, officialReceiptNumber, remarks, or changes.poolCode) when other certificates share the same policy number. Those fields apply to every certificate issued under that policy, so they can't be changed from a single certificate's amendment endpoint — cancel and re-transmit, or coordinate the change across every affected certificate instead.
The top-level error.code stays VALIDATION_FAILED, same as any other rejected amendment — look inside error.details[] for code: POLICY_SHARED_BY_OTHER_CERTIFICATES to identify this specific rule (each changed field gets its own detail entry, e.g. field: "changes.policy.premiumAmount"). See also status codes & errors.
POST /api/v1/ppai/policies/LTFRB-PPAI-2026-000012345/amendments
{
"externalTransactionId": "INSUR-20260918-000045",
"reason": "Corrected passenger capacity after operator resubmission.",
"changes": {
"vehicles": [
{
"plateNumber": "NGP1234",
"mvFileNumber": "123456789012345",
"engineNumber": "ENG123456",
"chassisNumber": "CHS1234567890",
"vehicleType": "JEEPNEY",
"denomination": "PUJ",
"passengerCapacity": 18,
"region": "NCR"
}
]
}
}8. Cancellations
Cancellation sets the record's lifecycle to CANCELLED — nothing is deleted, and the record remains visible for audit. policyNumber and cocNumber must match the record exactly (422 RECORD_MISMATCH otherwise). A supportingReference is required when reasonCode is ISSUED_IN_ERROR, REPLACED, FRANCHISE_REVOKED, or OTHER.
POST /api/v1/ppai/policies/LTFRB-PPAI-2026-000012345/cancellation
{
"externalTransactionId": "INSUR-20260920-000012",
"policyNumber": "POL-2026-00981",
"cocNumber": "COC-2026-00981",
"cancellationEffectiveDate": "2026-09-20",
"reasonCode": "OPERATOR_REQUEST",
"reasonText": "Operator requested cancellation; vehicle sold."
}9. Error handling
Every non-2xx response uses the same envelope with success: false and a stable error.code. Validation failures include error.details[] — read every entry, not just the first, since the pipeline collects all findings in one pass. See status codes & errors for the full reference.
10. Rate limits
- Per API client: 120 requests/minute by default (configurable per client).
- Token endpoint: 20 requests/minute per IP.
- Exceeding a limit returns
429 RATE_LIMITEDwith aRetry-Afterheader — honor it before retrying.