PagaFactu Implementation
Diagram, step-by-step calls, and examples in multiple languages.
Sequence diagram
Create a collection
A single multipart/form-data call. You get the payment URL and the PDF back.
Endpoint:
| Method | URL |
|---|---|
POST | https://nc-api-sandbox.zertiban.com/pagafactu/v1/flows/pagafactu |
Headers:
| Header | Value |
|---|---|
Authorization | Bearer {access_token} |
x-tenant-id | {businessUuid} |
Replace the values between braces {} with your own:
curl -X POST https://nc-api-sandbox.zertiban.com/pagafactu/v1/flows/pagafactu \
-H "Authorization: Bearer {access_token}" \
-H "x-tenant-id: {businessUuid}" \
-F 'payload={
"externalId": "INVOICE-2026-001",
"countryCode": "ES",
"operations": [{
"externalId": "OP-2026-001",
"configuration": "{configurationUuid}",
"payment": {
"amount": 15075,
"currency": "EUR",
"types": ["PSD2_PAYMENT"],
"psd2Payment": {
"type": "SINGLE_PAYMENT",
"product": "SEPA_CREDIT_TRANSFER",
"creditorAccount": { "uuid": "{creditorAccountUuid}" }
},
"concept": "Invoice 2026-001",
"debtor": {
"name": "Ana",
"lastName": "Martinez",
"email": "[email protected]"
}
},
"invoice": {
"externalId": "INV-2026-001",
"name": "Invoice 2026-001",
"generateDocument": true
}
}]
};type=application/json'
amountis in cents:15075= 150.75 EUR.externalIdis your internal identifier: it appears in webhooks and queries, letting you reconcile without storing Zertiban UUIDs.
Response (201 Created):
{
"uuid": "28ffd216-5ee8-4e99-b1a9-511961e9c655",
"createdAt": "2026-04-01T09:15:00Z",
"operations": [
{
"uuid": "b7aa09fe-5678-1234-90ab-cdef98765432",
"url": "https://nc-api-sandbox.zertiban.com/link-validator/v1/links/eyJhbGci...",
"document": "JVBERi0xLjQK..."
}
]
}Persist in your database:
| Field | Purpose |
|---|---|
operations[0].uuid | Status queries and webhook reconciliation. |
operations[0].url | Payment link to send to the customer. |
operations[0].document | Base64 PDF (only present if generateDocument: true). Decode: echo "JVBERi0..." | base64 -d > invoice.pdf. |
Creation variants
1. Zertiban-generated PDF with QR (default)
This is the scenario in the example above: "generateDocument": true with a name. Zertiban generates a new PDF with the payment QR and returns it in operations[0].document (Base64).
2. Without document
If you don't need a document, set "generateDocument": false:
"invoice": {
"externalId": "INV-2026-001",
"generateDocument": false
}The response won't include the document field. The name field in invoice is not required.
3. Your own PDF + Zertiban-appended page
If you want to attach your own PDF, keep "generateDocument": true, add "id": "my-invoice" in invoice and upload the file as a second multipart part. The part name must exactly match the value of invoice.id:
-F '[email protected];type=application/pdf'Zertiban appends a PagaFactu page to the end of your PDF (it does not replace it) with:
- Payment QR
- Direct link to the operation
Maximum PDF file size: 8 MB.
If you combine generateDocument: false with an attached file, the file is silently ignored and no document is returned.
4. Scheduled payment
If you want the payment to execute on a future date, change psd2Payment.type to "FUTURE_PAYMENT" and add the date:
"psd2Payment": {
"type": "FUTURE_PAYMENT",
"product": "SEPA_CREDIT_TRANSFER",
"requestedExecutionDate": "2026-05-01",
"creditorAccount": { "uuid": "{creditorAccountUuid}" }
}The date must be at least 1 business day in the future. Only available with SEPA_CREDIT_TRANSFER.
Send the link to the customer
Zertiban does not send communications to the payer. Delivering the link and/or document is your ERP's responsibility.
With the url and document from the previous response you have everything you need:
- Send the
urlby email, WhatsApp or SMS. - Attach the decoded PDF if you want the customer to have the document.
The customer can access the collection in two ways:
- Opening the direct link.
- Scanning the QR on the page appended to the PDF (variant 1 or 3).
In either case, the customer picks their bank and authorizes the transfer with their online banking (PSD2). They don't need to install anything.
Alternative: polling
If you'd rather not use webhooks for now, you can poll the status directly:
# Status of an operation
curl https://nc-api-sandbox.zertiban.com/flow/v1/operations/{operationUuid} \
-H "Authorization: Bearer {access_token}" \
-H "x-tenant-id: {businessUuid}"Response (key fields):
{
"uuid": "b7aa09fe-...",
"externalId": "OP-2026-001",
"status": "COMPLETED",
"type": "PAYMENT",
"flowUuid": "28ffd216-...",
"configurationUuid": "...",
"expirationOffset": "P30D",
"expiresAt": "2026-05-01T09:15:00Z",
"createdAt": "2026-04-01T09:15:00Z",
"payment": {
"paymentUuid": "...",
"amount": 15075,
"currency": "EUR",
"concept": "Invoice 2026-001",
"types": ["PSD2_PAYMENT"],
"psd2Payment": {
"type": "SINGLE_PAYMENT",
"product": "SEPA_CREDIT_TRANSFER",
"creditorAccount": { "uuid": "..." }
},
"subject": {
"name": "Ana",
"lastName": "Martinez",
"email": "[email protected]"
}
},
"invoice": {
"externalId": "INV-2026-001",
"dueDate": null,
"collectedAmount": null
}
}Field reference
POST/pagafactu/v1/flows/pagafactu, Content-Type: multipart/form-data
The body is a part named payload with type=application/json.
Root:
| Field | Type | Required | Description |
|---|---|---|---|
externalId | String | No | Your ID for this collection. Appears in webhooks and queries. If reused → 409 Conflict. |
countryCode | String | No | ISO 3166 alpha-2 code (e.g. "ES"). |
operations | Array | Yes | Exactly 1 element for PagaFactu. |
operations[0]:
| Field | Type | Required | Description |
|---|---|---|---|
externalId | String (max 50) | No | Your ID for this operation. Appears in resource.externalId in webhooks. |
configuration | UUID | No | Your configurationUuid. If omitted, the business's active default configuration is used. If provided with a non-UUID format → 400. If the provided configuration is disabled → 409 (the default is not applied as fallback). |
payment | Object | Yes | Payment data. |
invoice | Object | Yes | Invoice/document data. |
operations[0].payment:
| Field | Type | Required | Description |
|---|---|---|---|
amount | Integer | Yes | Amount in cents. 15075 = 150.75 EUR. Range: 1–9,999,999. |
currency | String | Yes | ISO 4217. Always "EUR". |
types | Array | Yes | Always ["PSD2_PAYMENT"]. |
concept | String (max 140) | Yes | Description shown in the payer's banking app when authorizing. |
psd2Payment | Object | Yes | PSD2 payment configuration. |
debtor | Object | No | Payer data. |
operations[0].payment.psd2Payment:
| Field | Type | Required | Description |
|---|---|---|---|
type | String | No | "SINGLE_PAYMENT" (immediate) or "FUTURE_PAYMENT" (scheduled). |
product | String | No | "SEPA_CREDIT_TRANSFER" (1–2 days) or "INSTANT_SEPA_CREDIT_TRANSFER". |
requestedExecutionDate | String YYYY-MM-DD | Only with FUTURE_PAYMENT | Must be at least 1 business day in the future. |
creditorAccount.uuid | UUID | No | Your creditorAccountUuid. If you omit the creditorAccount block, the business's active default creditor account is used. If provided with an invalid format (empty object or non-UUID uuid) → 400. The resolved account (explicit or automatic) must be ACTIVE or it is rejected with 409. In the 201 response, Zertiban returns the finally resolved account in operations[].payment.psd2Payment.creditorAccount.uuid. |
operations[0].payment.debtor:
| Field | Type | Required | Description |
|---|---|---|---|
name | String (max 100) | Yes, if debtor included | Payer's first name. |
lastName | String (max 100) | Yes, if debtor included | Payer's last name. |
email | String (max 255) | No | Valid email format. |
phone | String (max 20) | No | E.164 format (e.g. "+34600123456"). |
operations[0].invoice:
| Field | Type | Required | Description |
|---|---|---|---|
externalId | String | Yes | The invoice ID in your system. |
generateDocument | Boolean | Yes | true: generates a PDF with QR (or appends a PagaFactu page to your PDF). false: no document generated. |
name | String | Yes with generateDocument: true | Document name. |
id | String | Only if attaching a PDF | Must exactly match the multipart part name of the file. |
dueDate | String YYYY-MM-DD | No | Invoice due date. |
collectedAmount | Object | No | Amount already collected previously. |
operations[0].invoice.collectedAmount:
| Field | Type | Required | Description |
|---|---|---|---|
amount | Integer | Yes, if collectedAmount present | In cents. |
currency | String | Yes, if collectedAmount present | ISO 4217 (e.g. "EUR"). |
Creation response (201 Created)
| Field | Type | Description |
|---|---|---|
uuid | UUID | Flow identifier in Zertiban. |
createdAt | ISO 8601 | Creation timestamp. |
operations[0].uuid | UUID | Operation identifier. |
operations[0].url | URI | Payment link for the customer. |
operations[0].document | Base64 String | Generated PDF. Only present if generateDocument: true. |
Common endpoints
The satellite endpoints (get operation, list, cancel, history, statistics, etc.) are shared with ZertiPay and are documented in Business endpoints.
Common errors
| Code | Cause | Solution |
|---|---|---|
400 | Invalid payload (missing field, wrong format, more than 1 operation, configuration or creditorAccount.uuid with non-UUID format) | Review the required fields in the reference section. |
401 | Expired or invalid token | Request a new token. |
403 | Wrong x-tenant-id or missing permissions | Verify the businessUuid is correct. |
404 | Configuration or account UUID not found | Verify them in the Dashboard. |
409 | Duplicate externalId | Use a unique value per request. |
409 FLOW-SERVICE-CREDITOR-ACCOUNT-NOT-ACTIVE | The resolved creditor account (explicit or default) is not ACTIVE | Activate the account in the Dashboard or use another ACTIVE account. |
409 FLOW-SERVICE-DEFAULT-CREDITOR-ACCOUNT-NOT-FOUND | You did not provide creditorAccount and the business has no active default creditor account | Mark an account as default in the Dashboard or provide creditorAccount.uuid explicitly. |
409 (disabled configuration) | The provided configuration exists but is disabled | Enable it in the Dashboard or use another configurationUuid. |
409 (no default configuration) | You omitted configuration and the business has no active default configuration | Mark a configuration as default or provide configuration explicitly. |
PagaFactu integration checklist
Setup (one-off in the Dashboard)
- Business created →
businessUuid - Flow configuration created →
configurationUuid - 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 - If
generateDocument:true→ got the PDF inoperations[0].document -
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)
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) - Invoice 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 PagaFactu 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.