Skip to content
Developer Docs

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.

shell
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'
python
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"]
javascript
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;
java
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):

json
{
  "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}"
    }
  ]
}

amount in cents: 15075 = 150.75 EUR. | externalId appears in every webhook so you can reconcile without storing internal UUIDs. Current restriction: each flow contains a single payment operation. OpenAPI note: the service's openapi.yaml shows SINGLE_PAYMENTS and FUTURE_PAYMENTS (plural) in some schemas. That's a typo. The correct values, defined in the PaymentType enum in code, are SINGLE_PAYMENT and FUTURE_PAYMENT (singular).

Creation variants

SEPA Instant:

json
"psd2Payment": { "type": "SINGLE_PAYMENT", "product": "INSTANT_SEPA_CREDIT_TRANSFER",
  "creditorAccount": { "uuid": "{creditorAccountUuid}" } }

Scheduled payment (1–89 days in the future):

json
"psd2Payment": { "type": "FUTURE_PAYMENT", "product": "SEPA_CREDIT_TRANSFER",
  "requestedExecutionDate": "2026-05-15", "creditorAccount": { "uuid": "{creditorAccountUuid}" } }

With attached PDF:

shell
-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.

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:

shell
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:

FieldTypeReq.Description
externalIdStringNoYour flow ID
countryCodeStringYesISO 3166 (e.g. "ES")
additionalLanguageStringNoISO 639-1
operationsArrayYes1 operation in ZertiPay
documentsArrayNoAttached PDFs (up to 10)
labelsArrayNoKey-value labels

operations[i]:

FieldTypeReq.Description
idStringYesLocal request ID (@NotBlank)
typeStringYes"PAYMENT"
externalIdStringNoYour operation ID
configurationUUIDYesYour configurationUuid
paymentObjectYesPayment data

payment:

FieldTypeReq.Description
amountLongYesPositive, in cents
currencyStringYesISO 4217 (e.g. "EUR")
conceptStringYesShown in the payer's banking app
typesArray<String>YesAlways ["PSD2_PAYMENT"]
psd2PaymentObjectYesPSD2 configuration
documentsArrayNoReferences to documents[].id
debtorObjectNoPayer data (name, last name, email, phone)

psd2Payment:

FieldTypeReq.Description
typeStringYes"SINGLE_PAYMENT" or "FUTURE_PAYMENT"
productStringYes"SEPA_CREDIT_TRANSFER" or "INSTANT_SEPA_CREDIT_TRANSFER"
requestedExecutionDateYYYY-MM-DDOnly FUTURE_PAYMENT1–89 days in the future
creditorAccount.uuidUUIDYesYour creditorAccountUuid

Creation response (201)

FieldDescription
uuidFlow UUID
externalIdYour externalId
operations[i].uuidOperation UUID
operations[i].externalIdYour operation externalId
operations[i].idThe local id you sent
operations[i].urlPayment 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

CodeTypical causeSolution
400Invalid 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.
401Expired token or invalid credentials.New token with Basic Auth (not in the body).
403Wrong x-tenant-id or insufficient credential permissions.Verify businessUuid and credential permissions.
404Configuration, account, operation, flow or payment UUID not found.Verify with the GET endpoints.
409Cancel 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.url and redirection.callback.url set if applicable
  • API credentials created → clientId + clientSecret
  • IBAN registered by Zertiban → creditorAccountUuid
  • Webhook registered by Zertiban → webhookSecret
  • clientSecret and webhookSecret stored in a secrets manager (not in code)

Authentication

  • Token obtained with Basic Auth (clientId:clientSecret in 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].uuid and url
  • externalId unique per request (using my own business ID, not a Zertiban UUID)
  • amount sent 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.url receives 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_COMPLETED using my externalId
  • Out-of-order events tolerated (don't downgrade COMPLETED back to in-progress)

Negative scenarios tested

  • Operation cancelled by the payer → status and webhook correct
  • Operation expired → expiresAt honoured
  • 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 externalId and operationUuid, NOT the access_token or webhookSecret
  • 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 externalId as 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.