ZertiPay Implementation
Diagram, step-by-step calls, and examples in multiple languages.
End-to-end diagram
The WebApp and bank steps happen without your intervention. Your ERP only talks to the Client API (the arrows involving ZBN).
Create a collection
The endpoint uses multipart/form-data to allow attaching PDF documents in the same request. The structure is:
POST /flow/v1/flows
Content-Type: multipart/form-data; boundary=----boundary
------boundary
Content-Disposition: form-data; name="payload"
Content-Type: application/json ← required
{ "externalId": "...", "operations": [...] }
------boundary
Content-Disposition: form-data; name="doc-invoice"; filename="invoice.pdf"
Content-Type: application/pdf
<PDF binary>
------boundary--The payload part must carry Content-Type: application/json. If it doesn't, the server returns 400.
curl -X POST https://nc-api-sandbox.zertiban.com/flow/v1/flows \
-H "Authorization: Bearer {access_token}" \
-H "x-tenant-id: {businessUuid}" \
-F 'payload={
"externalId": "COLLECTION-2026-001",
"countryCode": "ES",
"operations": [{
"id": "op-1",
"type": "PAYMENT",
"externalId": "OP-2026-001",
"configuration": "{configurationUuid}",
"payment": {
"amount": 15075,
"currency": "EUR",
"concept": "Collection 2026-001",
"types": ["PSD2_PAYMENT"],
"psd2Payment": {
"type": "SINGLE_PAYMENT",
"product": "SEPA_CREDIT_TRANSFER",
"creditorAccount": { "uuid": "{creditorAccountUuid}" }
},
"debtor": { "name": "Ana", "lastName": "Martinez", "email": "[email protected]" }
}
}]
};type=application/json'import requests, json
payload = {
"externalId": "COLLECTION-2026-001", "countryCode": "ES",
"operations": [{ "id": "op-1", "type": "PAYMENT", "externalId": "OP-2026-001",
"configuration": CONFIGURATION_UUID,
"payment": { "amount": 15075, "currency": "EUR", "concept": "Collection 2026-001",
"types": ["PSD2_PAYMENT"],
"psd2Payment": { "type": "SINGLE_PAYMENT", "product": "SEPA_CREDIT_TRANSFER",
"creditorAccount": {"uuid": CREDITOR_ACCOUNT_UUID} } } }]
}
response = requests.post(
"https://nc-api-sandbox.zertiban.com/flow/v1/flows",
headers={"Authorization": f"Bearer {access_token}", "x-tenant-id": BUSINESS_UUID},
files={"payload": (None, json.dumps(payload), "application/json")}
)
data = response.json()
payment_url = data["operations"][0]["url"]const FormData = require('form-data');
const form = new FormData();
form.append('payload', JSON.stringify(payload), { contentType: 'application/json' });
const response = await axios.post('https://nc-api-sandbox.zertiban.com/flow/v1/flows', form, {
headers: { Authorization: `Bearer ${accessToken}`, 'x-tenant-id': BUSINESS_UUID, ...form.getHeaders() }
});
const paymentUrl = response.data.operations[0].url;MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("payload", objectMapper.writeValueAsString(request)).contentType(MediaType.APPLICATION_JSON);
FlowCreatedResponse result = webClient.post().uri("/flow/v1/flows")
.header("Authorization", "Bearer " + accessToken).header("x-tenant-id", businessUuid)
.contentType(MediaType.MULTIPART_FORM_DATA)
.body(BodyInserters.fromMultipartData(builder.build()))
.retrieve().bodyToMono(FlowCreatedResponse.class).block();Response (201 Created):
{
"uuid": "28ffd216-5ee8-4e99-b1a9-511961e9c655",
"externalId": "COLLECTION-2026-001",
"operations": [
{
"uuid": "d1faff9e-...",
"externalId": "OP-2026-001",
"id": "op-1",
"url": "https://zertiban.com/{businessUuid}/{operationUuid}"
}
]
}
amountin cents:15075= 150.75 EUR. |externalIdappears in every webhook so you can reconcile without storing internal UUIDs. Current restriction: each flow contains a single payment operation. OpenAPI note: the service'sopenapi.yamlshowsSINGLE_PAYMENTSandFUTURE_PAYMENTS(plural) in some schemas. That's a typo. The correct values, defined in thePaymentTypeenum in code, areSINGLE_PAYMENTandFUTURE_PAYMENT(singular).
Creation variants
SEPA Instant:
"psd2Payment": { "type": "SINGLE_PAYMENT", "product": "INSTANT_SEPA_CREDIT_TRANSFER",
"creditorAccount": { "uuid": "{creditorAccountUuid}" } }Scheduled payment (1–89 days in the future):
"psd2Payment": { "type": "FUTURE_PAYMENT", "product": "SEPA_CREDIT_TRANSFER",
"requestedExecutionDate": "2026-05-15", "creditorAccount": { "uuid": "{creditorAccountUuid}" } }With attached PDF:
-F 'payload={ "documents":[{"id":"invoice","name":"invoice.pdf"}],
"operations":[{ "payment":{ "documents":[{"documentId":"invoice"}] } }]
};type=application/json' \
-F 'invoice=@./invoice.pdf;type=application/pdf'The file's part name must exactly match documents[].id.
Send the link to the payer
Distribute the url through any channel. The payer opens the link, picks their bank and authorizes the transfer. If you configured redirection.callback.url, their browser will be redirected automatically when the operation reaches a final state.
Alternative: polling
If you'd rather not use webhooks for now, you can poll the status directly:
curl https://nc-api-sandbox.zertiban.com/flow/v1/operations/{operationUuid}/status \
-H "Authorization: Bearer {access_token}" -H "x-tenant-id: {businessUuid}"Field reference POST /flow/v1/flows
Content-Type: multipart/form-data.
Payload root:
| Field | Type | Req. | Description |
|---|---|---|---|
externalId | String | No | Your flow ID |
countryCode | String | Yes | ISO 3166 (e.g. "ES") |
additionalLanguage | String | No | ISO 639-1 |
operations | Array | Yes | 1 operation in ZertiPay |
documents | Array | No | Attached PDFs (up to 10) |
labels | Array | No | Key-value labels |
operations[i]:
| Field | Type | Req. | Description |
|---|---|---|---|
id | String | Yes | Local request ID (@NotBlank) |
type | String | Yes | "PAYMENT" |
externalId | String | No | Your operation ID |
configuration | UUID | Yes | Your configurationUuid |
payment | Object | Yes | Payment data |
payment:
| Field | Type | Req. | Description |
|---|---|---|---|
amount | Long | Yes | Positive, in cents |
currency | String | Yes | ISO 4217 (e.g. "EUR") |
concept | String | Yes | Shown in the payer's banking app |
types | Array<String> | Yes | Always ["PSD2_PAYMENT"] |
psd2Payment | Object | Yes | PSD2 configuration |
documents | Array | No | References to documents[].id |
debtor | Object | No | Payer data (name, last name, email, phone) |
psd2Payment:
| Field | Type | Req. | Description |
|---|---|---|---|
type | String | Yes | "SINGLE_PAYMENT" or "FUTURE_PAYMENT" |
product | String | Yes | "SEPA_CREDIT_TRANSFER" or "INSTANT_SEPA_CREDIT_TRANSFER" |
requestedExecutionDate | YYYY-MM-DD | Only FUTURE_PAYMENT | 1–89 days in the future |
creditorAccount.uuid | UUID | Yes | Your creditorAccountUuid |
Creation response (201)
| Field | Description |
|---|---|
uuid | Flow UUID |
externalId | Your externalId |
operations[i].uuid | Operation UUID |
operations[i].externalId | Your operation externalId |
operations[i].id | The local id you sent |
operations[i].url | Payment link for the payer |
Common endpoints
The satellite endpoints (quick status, detail, listing, cancellation, expiration extension, document download, history, statistics and PSD2 payment detail) are shared with PagaFactu and are documented in Business endpoints.
Common errors
| Code | Typical cause | Solution |
|---|---|---|
400 | Invalid payload: missing field, wrong types (use PSD2_PAYMENT, not SINGLE_PAYMENTS), requestedExecutionDate out of 1–89 day range, non-positive amount, duration without D designator. | Review the validation in the reference section. |
401 | Expired token or invalid credentials. | New token with Basic Auth (not in the body). |
403 | Wrong x-tenant-id or insufficient credential permissions. | Verify businessUuid and credential permissions. |
404 | Configuration, account, operation, flow or payment UUID not found. | Verify with the GET endpoints. |
409 | Cancel already-finished operation, or operation with payment in progress. | Check the status first. |
ZertiPay integration checklist
Setup (one-off in the Dashboard)
- Business created →
businessUuid - Flow configuration created →
configurationUuid-
redirection.return.urlandredirection.callback.urlset if applicable
-
- API credentials created →
clientId+clientSecret - IBAN registered by Zertiban →
creditorAccountUuid - Webhook registered by Zertiban →
webhookSecret -
clientSecretandwebhookSecretstored in a secrets manager (not in code)
Authentication
- Token obtained with Basic Auth (
clientId:clientSecretin the header, not in the body) - Token cached and refreshed before expiry (not one per request)
- 401 triggers a refresh + single retry
Collection creation
- First 201 obtained in Sandbox → got
uuid,operations[0].uuidandurl -
externalIdunique per request (using my own business ID, not a Zertiban UUID) -
amountsent in cents verified with an edge case (e.g. 1, 9,999,999) - Scheduled-payment variant (
FUTURE_PAYMENT) tested if you plan to use it - 4xx errors (400/403/404/409) logged with the exact code and not retried
Payer distribution
- Link opened in private browsing and on mobile
- Payment completed in Sandbox against a real bank (minimum amount)
-
callback.urlreceives the redirection on final state (if you configured it)
Webhooks and reconciliation
- Endpoint publicly reachable from the internet (not localhost) and always HTTPS
- Endpoint responds 2xx in under 5 s; heavy work goes to a queue
- HMAC signature verified before processing the body
- Deduplication by
eventUuid(event-level idempotency) - Collection updated in the ERP after
OPERATION_COMPLETEDusing myexternalId - Out-of-order events tolerated (don't downgrade
COMPLETEDback to in-progress)
Negative scenarios tested
- Operation cancelled by the payer → status and webhook correct
- Operation expired →
expiresAthonoured - Webhook delivered twice → collection isn't duplicated
- Zertiban 5xx → retry with exponential backoff; 4xx → no retry
- Manual webhook redelivery produces the same result
Observability
- Logs include
externalIdandoperationUuid, NOT theaccess_tokenorwebhookSecret - Alert if no webhooks are received for X minutes during business hours
- Metric of % collections completed vs created
Promotion to production
- Everything above green in Sandbox
- Credentials and URLs switched to production in the secrets manager
- Contact Zertiban for Production activation
End state
A correct ZertiPay integration:
- Doesn't depend on Zertiban's internal UUIDs: uses your
externalIdas the business key for reconciliation. - Uses webhooks as the primary source of truth for collection state.
- Avoids polling in production: keep it for bootstrap/testing or as a safety net.