API documentation

    Create An Post labels from your own software, read their status and read your shipping balance. Contract version 1.9.0.

    Conventions

    • Base URL https://www.eirpost.ie/api/v1. JSON in and out, UTF-8.
    • Money is always integer cents in euro. Times are ISO 8601 in UTC.
    • Every price is computed on the server from the shared rates file. Bands are up to 0.5, 1, 1.5 and 2 kg, and nothing above 2 kg is accepted.
    • Great Britain is priced as GB-NI or GB-GB: a BT postcode is Northern Ireland, any other GB postcode is Great Britain, and destination.region wins when you send it.
    • POST /v1/labels needs an Idempotency-Key header equal to body.reference, and a batch needs a batch key. The same key with the same body replays the stored state; the same key with a different body is a conflict.
    • Lookups after a conflict or a lost response take percent-encoded query parameters, never path segments.
    • There is no cancel endpoint. Email contact@eirpost.ie with the tracking number, and an approved cancellation credits your balance.
    • The company line on every EirPost label reads Rofsen, the registered business name on the An Post account.

    Rate limits

    POST /v1/labels, POST /v1/labels/batch, POST /v1/quotes60 per minute per connection
    every other v1 endpoint300 per minute per connection
    POST /v1/connect/exchange, /ack30 per minute per client

    Authentication and scopes

    Send Authorization: Bearer <token>. Tokens are shown once at creation and stored hashed. Create one under Settings, Connections and API access.

    Token prefixes

    epk_live_Manual API key, live labels
    epk_test_Manual API key, test labels (An Post test mode, nothing charged)
    epc_live_EirLink connection, live labels

    Scopes

    labels:createCreate labels and quotes
    labels:readRead labels, their status and PDFs
    balance:readRead the account, balance and notices
    tracking:readRead tracking events

    Errors

    Every error body is {"error": {code, message, retryable, ...}}. The booking column is what the code proves about a create.

    CodeHTTPRetryableBookingMeaning
    bad_request400nonot_bookedMalformed JSON, a missing Idempotency-Key header, or a body that is not an object.
    unauthenticated401nonot_bookedNo bearer token, or a token that does not exist.
    connection_revoked401nonot_bookedThe token belongs to a revoked connection or key. EirLink clears the stored credential on this code.
    insufficient_scope403nonot_bookedThe token lacks the scope the endpoint needs.
    insufficient_balance402nonot_bookedThe available balance does not cover the priced label(s). Nothing was held. Extras: required_cents, available_cents, shortfall_cents, top_up_url (all required on this code).
    not_found404non/aNo such label, connection or resource for this account.
    label_not_ready404yesn/aThe label exists at An Post but its PDF is not held yet. Try again later.
    idempotency_conflict409noambiguousThe Idempotency-Key was already used with a different body. Nothing new was created, but the original request exists: resolve it through GET /v1/labels?reference= (single), GET /v1/labels/batch?key={key} (batch) or GET /api/balance/pay?order_id={order_id} (site) before doing anything else.
    reference_in_use409noambiguousA label of this account already carries this reference (batch children included), so a parcel may exist: resolve it through GET /v1/labels?reference= and use a fresh reference for any new attempt.
    already_credited409non/aThe cancellation credit for this label already exists. Extras: entry.
    validation_failed422nonot_bookedA field is missing, malformed or outside the catalogue (weight above 2kg, unsupported country, region on a non-GB address, a phone with "@"). Extras: field_errors keyed by dotted path. Nothing was held.
    account_blocked423nonot_bookedThe balance is negative or the account is blocked. Top up or contact EirPost. Nothing was held.
    rate_limited429yesnot_bookedToo many requests for this connection. Nothing was processed. Honour Retry-After.
    amount_too_low400non/aTop-up below the minimum. Extras: min_cents (required).
    insufficient_points400non/aFewer than 1,000 referral points. Extras: required_points, available_points (required).
    new_account_limit400non/aTop-up above the new-account limit. Extras: max_cents, daily_remaining_cents (required).
    invalid_client401non/aHandshake: client_id or client_secret is wrong.
    invalid_grant400non/aHandshake: the code is unknown, expired, already used by another client, or bound to a different redirect_uri.
    state_invalid404non/aHandshake: the connect state is unknown, expired, already used or already approved.
    shopify_flow_retired410non/aThe EirPost Shopify app has been retired. Extras: link (required).
    carrier_failed502nonot_bookedAn Post definitively refused the parcel without a validation map (authentication, exhausted rate-limit retries, another 4xx). Nothing was booked and the hold was released.
    maintenance503yesnot_bookedThe balance rollout is switched off for this account, the signed-in site route has no active EirLink connection, or label creation is paused for maintenance. Webhooks still record and nothing was booked.
    internal_error500noambiguousUnexpected server failure. Treat a create as ambiguous: resolve through GET /v1/labels?reference= (none means nothing was created and the reconciliation may re-send the same request once; retryable false only forbids an immediate blind repeat).

    Booking outcomes

    What to record for every outcome of POST /v1/labels. Booked: settle the shipment. Not booked: nothing exists, retry freely with a fresh reference. Ambiguous: park the shipment and let GET /v1/labels?reference= decide.

    ResponseStatusBookingAction
    201createdbookedLabel created; tracking present; label PDF present or label_pending true.
    200 (replay) status createdcreatedbookedSame as 201.
    200 (replay) status failedfailednot_bookedThe original attempt failed definitively; error carries the code.
    200 (replay) status pending_confirmationpendingambiguousHold open; poll the reference status.
    200 (replay) status requested or dispatchingin progressambiguousThe first call is still running; poll the reference status, never re-POST.
    202pending_confirmationambiguousAn Post gave no usable answer after dispatch; the hold stays until resolved.
    400, 401, 403, 402, 422, 423, 502, 503see the codenot_bookedNothing exists; any hold was released.
    409 idempotency_conflict, 409 reference_in_useconflictambiguousA label with that key or reference exists: resolve through the reference status (or the batch or order lookup) before anything else.
    429rate_limitednot_bookedRetry the identical request after Retry-After, within a bounded budget.
    500internal_errorambiguousResolve through the reference status: none means re-send once with the same key and body.
    network error before the request body was sentnot_bookednot_bookedRetry once.
    timeout, reset or unreadable answer after the body was sentunknownambiguousResolve through the reference status; never re-POST blindly.

    Reference status actions

    GET /v1/labels?reference= answersA parked shipment does this
    noneNo label row exists: the request never reached EirPost's label table, so nothing reached An Post. As a reconciliation action (not an immediate retry), re-send the identical create once (same Idempotency-Key, same body) and map that result; if the re-send is impossible (credential gone) mark the shipment failed.
    requested, dispatchingLeave parked; check again next sweep.
    pending_confirmationLeave parked; EirPost resolves it by tracking lookup or admin action.
    createdAdopt the label (tracking, PDF through GET /v1/labels/{id}/pdf).
    failedMark the shipment failed with the label's error code; a fresh attempt needs a fresh reference.

    Ledger entry types

    Every movement on a shipping balance carries one of these types.

    • top_up
    • label_debit
    • weight_adjustment
    • cancellation_credit
    • manual_adjustment
    • chargeback_reversal
    • chargeback_won
    • referral_credit

    Public API v1

    GET/api/v1/account

    The account behind the token: plan, balance, catalogue, notices.

    Auth: Bearer token, scope balance:read.

    Responses: 200 Account.

    Errors: unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503).

    Example: plus account with a balance

    Response 200

    {
      "email": "merchant@example.test",
      "plan": "plus",
      "rate_tier": "discounted",
      "free_uses_remaining": 0,
      "balance_cents": 4410,
      "held_cents": 0,
      "available_cents": 4410,
      "blocked": false,
      "blocked_reason": null,
      "test": false,
      "catalogue": {
        "max_weight_kg": 2,
        "pricing_keys": [
          "IE",
          "GB-NI",
          "GB-GB",
          "DE",
          "FR",
          "US"
        ]
      },
      "notices": [],
      "rate_version": "2026-09-03"
    }

    POST/api/v1/account/notices/ack

    Acknowledge a notice so it stops appearing on this account (in both products).

    Auth: Bearer token, scope balance:read.

    Request body: NoticeAck

    FieldTypeRequiredNotes
    codestringyesone of weight_accuracy
    occasionstringyesone of signup, third_batch

    Responses: 200 AcknowledgementResponse.

    Errors: validation_failed (422), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503).

    Example: ack

    Request body

    {
      "code": "weight_accuracy",
      "occasion": "signup"
    }

    Response 200

    {
      "acknowledged": true
    }

    POST/api/v1/quotes

    Price parcels in order, applying free uses in sequence. Informational for 15 minutes; create reprices.

    Auth: Bearer token, scope labels:create.

    Request body: QuoteRequest

    FieldTypeRequiredNotes
    parcelsarray of QuoteParcelyesmin items 1, max items 100

    Responses: 200 QuoteResponse.

    Errors: validation_failed (422), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503).

    Example: two parcels, one short

    Request body

    {
      "parcels": [
        {
          "country": "GB",
          "postcode": "BT1 1AA",
          "weight_kg": 0.45
        },
        {
          "country": "US",
          "weight_kg": 1.2
        }
      ]
    }

    Response 200

    {
      "parcels": [
        {
          "price_cents": 800,
          "rate_tier": "discounted",
          "band": "0.5",
          "pricing_key": "GB-NI"
        },
        {
          "price_cents": 2400,
          "rate_tier": "discounted",
          "band": "1.5",
          "pricing_key": "US"
        }
      ],
      "total_cents": 3200,
      "available_cents": 1000,
      "shortfall_cents": 2200,
      "test": false,
      "rate_version": "2026-09-03",
      "expires_at": "2026-09-03T10:27:04Z"
    }

    Example: weight above the catalogue

    Request body

    {
      "parcels": [
        {
          "country": "IE",
          "weight_kg": 2.4
        }
      ]
    }

    Response 422

    {
      "error": {
        "code": "validation_failed",
        "message": "Parcels up to 2kg only",
        "retryable": false,
        "field_errors": {
          "parcels.0.weight_kg": [
            "must be at most 2"
          ]
        }
      }
    }

    POST/api/v1/labels

    Price, hold, create the An Post parcel and settle. One call, one label, one hold.

    Auth: Bearer token, scope labels:create.

    Header Idempotency-Key (1 to 128 characters) is required and must equal body.reference. The first call with a reference prices the parcel, opens the hold and dispatches to An Post. A repeat with the same reference and the same body returns 200 with the current state of that label (Label shape; status created, failed, pending_confirmation, dispatching or requested) and never opens a second hold. A repeat with the same reference and a different body is 409 idempotency_conflict; a reference already used by another label of the account is 409 reference_in_use. Outcomes: 201 created (label may be null with label_pending true), 202 pending_confirmation (An Post gave no usable answer after dispatch; the hold stays until the parcel is confirmed by lookup or by EirPost, never released on silence), 402 insufficient_balance, 422 validation_failed (nothing held), 423 account_blocked, 502 carrier_failed (An Post refused; the hold was released), 503 maintenance. See the booking outcome table. Test tokens create An Post test parcels: no hold, no charge, price_cents 0, balance_after_cents null, test true, same lifecycle. The response notices are the notices active after this label was counted.

    Headers: Idempotency-Key (required, 1 to 128 characters, equal to body.reference; validated (Headers definition) before any transaction).

    Request body: LabelCreate

    FieldTypeRequiredNotes
    referencestringyesRecorded on the label and returned on every response about it. Unique per account across every label ever created (batch children included); a fresh attempt needs a fresh reference. The shipper reference sent to the carrier is derived from this one and is unique across every account, so two merchants may use the same reference without either one reaching the other’s parcel. For POST /v1/labels it must equal the Idempotency-Key header.. pattern ^[A-Za-z0-9._:#/-]{1,128}$
    order_referencestringyesmin length 1, max length 128
    descriptionstringyesmin length 1, max length 255
    delivery_instructionstring, nullablenomax length 255
    packagePackageyes
    senderSenderyesThe address printed as origin and return address. Occupies exactly the fields a saved site return address occupies. The company line on the label always reads Rofsen whatever company_name is supplied; the supplied value is stored for display only. country must be IE.
    destinationDestinationyes
    itemsarray of Itemyesmin items 1, max items 50
    optionsOptions, nullableno

    Responses: 200 Label; 201 Label; 202 LabelPending.

    Errors: bad_request (400), validation_failed (422), insufficient_balance (402), account_blocked (423), idempotency_conflict (409), reference_in_use (409), carrier_failed (502), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503), internal_error (500).

    Example: GB-NI label from a Plus account

    Request headers

    {
      "Idempotency-Key": "ORD-1157-1725350000-ab12"
    }

    Request body

    {
      "reference": "ORD-1157-1725350000-ab12",
      "order_reference": "#1157",
      "description": "Wax melts",
      "delivery_instruction": null,
      "package": {
        "weight_kg": 0.45,
        "value": 13.8,
        "currency": "EUR",
        "dimensions_cm": {
          "length": 20,
          "width": 15,
          "height": 5
        }
      },
      "sender": {
        "name": "Sample Sender",
        "company_name": "Sample Studio",
        "address_line_1": "1 Example Road",
        "address_line_2": null,
        "address_line_3": null,
        "house_number": null,
        "city": "Dublin",
        "state": "Co. Dublin",
        "zip": "D01 X000",
        "country": "IE",
        "phone": "0851234567",
        "email": "sender@example.test",
        "vat": null,
        "ioss": null,
        "eori": null
      },
      "destination": {
        "name": "Sample Buyer",
        "company": null,
        "address1": "2 Example Street",
        "address2": null,
        "city": "Belfast",
        "state": "Antrim",
        "zip": "BT1 1AA",
        "country": "GB",
        "region": "GB-NI",
        "phone": null,
        "email": "buyer@example.test"
      },
      "items": [
        {
          "description": "Wax melts",
          "quantity": 2,
          "unit_value": 6.9,
          "weight_kg": 0.2,
          "hs_code": "340600",
          "country_of_origin": "IE",
          "sku": "WM-2",
          "product_url": null,
          "image_url": null
        }
      ],
      "options": {
        "customs_category": "sale_of_goods",
        "recipient_vat_number": null
      }
    }

    Response 201

    {
      "id": 8123,
      "status": "created",
      "test": false,
      "label_pending": false,
      "reference": "ORD-1157-1725350000-ab12",
      "order_reference": "#1157",
      "price_cents": 800,
      "rate_tier": "discounted",
      "band": "0.5",
      "pricing_key": "GB-NI",
      "balance_after_cents": 3610,
      "tracking": {
        "system": "AIRSTUB1",
        "shipper": "LX000000001IE",
        "last_mile": "LX000000001IE"
      },
      "label": {
        "content_type": "application/pdf",
        "data_base64": "JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjg4IDQzMl0+PmVuZG9iagp0cmFpbGVyPDwvUm9vdCAxIDAgUj4+CiUlRU9GCg=="
      },
      "error": null,
      "notices": [],
      "created_at": "2026-09-03T10:12:04Z",
      "updated_at": "2026-09-03T10:12:05Z"
    }

    Example: insufficient balance

    Request headers

    {
      "Idempotency-Key": "ORD-1158-1725350100-cd34"
    }

    Request body

    {
      "reference": "ORD-1158-1725350100-cd34",
      "order_reference": "#1158",
      "description": "Wool scarf",
      "delivery_instruction": null,
      "package": {
        "weight_kg": 1.2,
        "value": 48,
        "currency": "EUR",
        "dimensions_cm": null
      },
      "sender": {
        "name": "Sample Sender",
        "company_name": "Sample Studio",
        "address_line_1": "1 Example Road",
        "address_line_2": null,
        "address_line_3": null,
        "house_number": null,
        "city": "Dublin",
        "state": "Co. Dublin",
        "zip": "D01 X000",
        "country": "IE",
        "phone": "0851234567",
        "email": "sender@example.test",
        "vat": null,
        "ioss": null,
        "eori": null
      },
      "destination": {
        "name": "Sample Buyer",
        "company": null,
        "address1": "3 Example Avenue",
        "address2": "Apt 4",
        "city": "Portland",
        "state": "OR",
        "zip": "97201",
        "country": "US",
        "region": null,
        "phone": "5035550100",
        "email": "buyer@example.test"
      },
      "items": [
        {
          "description": "Wool scarf",
          "quantity": 1,
          "unit_value": 48,
          "weight_kg": 1.1,
          "hs_code": "621420",
          "country_of_origin": "IE",
          "sku": "SC-1",
          "product_url": null,
          "image_url": null
        }
      ],
      "options": {
        "customs_category": "sale_of_goods",
        "recipient_vat_number": null
      }
    }

    Response 402

    {
      "error": {
        "code": "insufficient_balance",
        "message": "€24.00 needed, €10.00 available. Top up at eirpost.ie/balance",
        "retryable": false,
        "required_cents": 2400,
        "available_cents": 1000,
        "shortfall_cents": 1400,
        "top_up_url": "https://www.eirpost.ie/balance"
      }
    }

    Example: reference differs from the key

    Request headers

    {
      "Idempotency-Key": "ORD-other"
    }

    Request body

    {
      "reference": "ORD-1159-1725350200-ef56",
      "order_reference": "#1159",
      "description": "Candle",
      "delivery_instruction": null,
      "package": {
        "weight_kg": 0.4,
        "value": 12,
        "currency": "EUR",
        "dimensions_cm": null
      },
      "sender": {
        "name": "Sample Sender",
        "company_name": "Sample Studio",
        "address_line_1": "1 Example Road",
        "address_line_2": null,
        "address_line_3": null,
        "house_number": null,
        "city": "Dublin",
        "state": "Co. Dublin",
        "zip": "D01 X000",
        "country": "IE",
        "phone": "0851234567",
        "email": "sender@example.test",
        "vat": null,
        "ioss": null,
        "eori": null
      },
      "destination": {
        "name": "Sample Buyer",
        "company": null,
        "address1": "4 Example Lane",
        "address2": null,
        "city": "Cork",
        "state": "Co. Cork",
        "zip": "T12 AB00",
        "country": "IE",
        "region": null,
        "phone": null,
        "email": null
      },
      "items": [
        {
          "description": "Candle",
          "quantity": 1,
          "unit_value": 12,
          "weight_kg": 0.4,
          "hs_code": "340600",
          "country_of_origin": "IE",
          "sku": "CN-1",
          "product_url": null,
          "image_url": null
        }
      ],
      "options": null
    }

    Response 422

    {
      "error": {
        "code": "validation_failed",
        "message": "The Idempotency-Key must equal the reference",
        "retryable": false,
        "field_errors": {
          "reference": [
            "must equal the Idempotency-Key header"
          ]
        }
      }
    }

    POST/api/v1/labels/batch

    Up to 100 labels, priced together, one child hold per label, all-or-nothing balance check.

    Auth: Bearer token, scope labels:create.

    Header Idempotency-Key required (the batch key, unique per account). Every label is validated first (422 with field_errors keyed labels.<index>..., nothing held); references must be distinct and unused. The balance must cover the sum of every label at once (402 with the total, nothing held). Then each label dispatches in order on its own hold, so one parcel failing at An Post never affects the others. Responds 200 with per-item results. A repeat with the same batch key and body returns the current state of every child; a different body is 409 idempotency_conflict.

    Headers: Idempotency-Key (required, 1 to 128 characters, the batch key; validated before any transaction).

    Request body: BatchCreate

    FieldTypeRequiredNotes
    labelsarray of LabelCreateyesReferences must be distinct within the batch and unused by the account.. min items 1, max items 100

    Responses: 200 BatchResponse.

    Errors: bad_request (400), validation_failed (422), insufficient_balance (402), account_blocked (423), idempotency_conflict (409), reference_in_use (409), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503), internal_error (500).

    Example: two labels, second refused by An Post

    Request headers

    {
      "Idempotency-Key": "batch-2026-09-03-0001"
    }

    Request body

    {
      "labels": [
        {
          "reference": "ORD-1159-1725350200-ef56",
          "order_reference": "#1159",
          "description": "Candle",
          "delivery_instruction": null,
          "package": {
            "weight_kg": 0.4,
            "value": 12,
            "currency": "EUR",
            "dimensions_cm": null
          },
          "sender": {
            "name": "Sample Sender",
            "company_name": "Sample Studio",
            "address_line_1": "1 Example Road",
            "address_line_2": null,
            "address_line_3": null,
            "house_number": null,
            "city": "Dublin",
            "state": "Co. Dublin",
            "zip": "D01 X000",
            "country": "IE",
            "phone": "0851234567",
            "email": "sender@example.test",
            "vat": null,
            "ioss": null,
            "eori": null
          },
          "destination": {
            "name": "Sample Buyer",
            "company": null,
            "address1": "4 Example Lane",
            "address2": null,
            "city": "Cork",
            "state": "Co. Cork",
            "zip": "T12 AB00",
            "country": "IE",
            "region": null,
            "phone": null,
            "email": null
          },
          "items": [
            {
              "description": "Candle",
              "quantity": 1,
              "unit_value": 12,
              "weight_kg": 0.4,
              "hs_code": "340600",
              "country_of_origin": "IE",
              "sku": "CN-1",
              "product_url": null,
              "image_url": null
            }
          ],
          "options": null
        },
        {
          "reference": "ORD-1161-1725350400-gh78-STUB-validation_422",
          "order_reference": "#1161",
          "description": "Candle",
          "delivery_instruction": null,
          "package": {
            "weight_kg": 0.4,
            "value": 12,
            "currency": "EUR",
            "dimensions_cm": null
          },
          "sender": {
            "name": "Sample Sender",
            "company_name": "Sample Studio",
            "address_line_1": "1 Example Road",
            "address_line_2": null,
            "address_line_3": null,
            "house_number": null,
            "city": "Dublin",
            "state": "Co. Dublin",
            "zip": "D01 X000",
            "country": "IE",
            "phone": "0851234567",
            "email": "sender@example.test",
            "vat": null,
            "ioss": null,
            "eori": null
          },
          "destination": {
            "name": "Sample Buyer",
            "company": null,
            "address1": "5 Example Lane",
            "address2": null,
            "city": "Galway",
            "state": "Co. Galway",
            "zip": "H91 AB00",
            "country": "IE",
            "region": null,
            "phone": null,
            "email": null
          },
          "items": [
            {
              "description": "Candle",
              "quantity": 1,
              "unit_value": 12,
              "weight_kg": 0.4,
              "hs_code": "340600",
              "country_of_origin": "IE",
              "sku": "CN-1",
              "product_url": null,
              "image_url": null
            }
          ],
          "options": null
        }
      ]
    }

    Response 200

    {
      "batch_id": 41,
      "label_count": 2,
      "total_cents": 1380,
      "balance_after_cents": 3720,
      "results": [
        {
          "index": 0,
          "status": "created",
          "label": {
            "id": 8124,
            "status": "created",
            "test": false,
            "label_pending": false,
            "reference": "ORD-1159-1725350200-ef56",
            "order_reference": "#1159",
            "price_cents": 690,
            "rate_tier": "discounted",
            "band": "0.5",
            "pricing_key": "IE",
            "balance_after_cents": 3720,
            "tracking": {
              "system": "AIRSTUB2",
              "shipper": "CB000000002IE",
              "last_mile": "CB000000002IE"
            },
            "label": {
              "content_type": "application/pdf",
              "data_base64": "JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjg4IDQzMl0+PmVuZG9iagp0cmFpbGVyPDwvUm9vdCAxIDAgUj4+CiUlRU9GCg=="
            },
            "error": null,
            "notices": [],
            "created_at": "2026-09-03T10:14:04Z",
            "updated_at": "2026-09-03T10:14:05Z"
          }
        },
        {
          "index": 1,
          "status": "failed",
          "label": {
            "id": 8125,
            "status": "failed",
            "test": false,
            "label_pending": false,
            "reference": "ORD-1161-1725350400-gh78-STUB-validation_422",
            "order_reference": "#1161",
            "price_cents": 690,
            "rate_tier": "discounted",
            "band": "0.5",
            "pricing_key": "IE",
            "balance_after_cents": null,
            "tracking": null,
            "label": null,
            "error": {
              "code": "validation_failed",
              "message": "An Post rejected the shipment details",
              "field_errors": {
                "destination_address.zip": [
                  "The zip field is required."
                ]
              }
            },
            "notices": [],
            "created_at": "2026-09-03T10:14:06Z",
            "updated_at": "2026-09-03T10:14:07Z"
          }
        }
      ]
    }

    GET/api/v1/labels/batch?key={key}

    The current state of a batch by its Idempotency-Key: the lookup a client uses after a conflict or a lost response.

    Auth: Bearer token, scope labels:read.

    key is the batch Idempotency-Key as a percent-encoded query parameter (encodeURIComponent), never a path segment: keys containing /, # or % must round-trip, which the implementation tests.

    Responses: 200 BatchResponse.

    Errors: not_found (404), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503).

    Example: batch state

    Response 200

    {
      "batch_id": 41,
      "label_count": 2,
      "total_cents": 1380,
      "balance_after_cents": 3720,
      "results": [
        {
          "index": 0,
          "status": "created",
          "label": {
            "id": 8124,
            "status": "created",
            "test": false,
            "label_pending": false,
            "reference": "ORD-1159-1725350200-ef56",
            "order_reference": "#1159",
            "price_cents": 690,
            "rate_tier": "discounted",
            "band": "0.5",
            "pricing_key": "IE",
            "balance_after_cents": 3720,
            "tracking": {
              "system": "AIRSTUB2",
              "shipper": "CB000000002IE",
              "last_mile": "CB000000002IE"
            },
            "label": {
              "content_type": "application/pdf",
              "data_base64": "JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjg4IDQzMl0+PmVuZG9iagp0cmFpbGVyPDwvUm9vdCAxIDAgUj4+CiUlRU9GCg=="
            },
            "error": null,
            "notices": [],
            "created_at": "2026-09-03T10:14:04Z",
            "updated_at": "2026-09-03T10:14:05Z"
          }
        },
        {
          "index": 1,
          "status": "failed",
          "label": {
            "id": 8125,
            "status": "failed",
            "test": false,
            "label_pending": false,
            "reference": "ORD-1161-1725350400-gh78-STUB-validation_422",
            "order_reference": "#1161",
            "price_cents": 690,
            "rate_tier": "discounted",
            "band": "0.5",
            "pricing_key": "IE",
            "balance_after_cents": null,
            "tracking": null,
            "label": null,
            "error": {
              "code": "validation_failed",
              "message": "An Post rejected the shipment details",
              "field_errors": {
                "destination_address.zip": [
                  "The zip field is required."
                ]
              }
            },
            "notices": [],
            "created_at": "2026-09-03T10:14:06Z",
            "updated_at": "2026-09-03T10:14:07Z"
          }
        }
      ]
    }

    GET/api/v1/labels/{id}

    One label with its current state.

    Auth: Bearer token, scope labels:read.

    Responses: 200 Label.

    Errors: not_found (404), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503).

    Example: created label

    Response 200

    {
      "id": 8123,
      "status": "created",
      "test": false,
      "label_pending": false,
      "reference": "ORD-1157-1725350000-ab12",
      "order_reference": "#1157",
      "price_cents": 800,
      "rate_tier": "discounted",
      "band": "0.5",
      "pricing_key": "GB-NI",
      "balance_after_cents": 3610,
      "tracking": {
        "system": "AIRSTUB1",
        "shipper": "LX000000001IE",
        "last_mile": "LX000000001IE"
      },
      "label": {
        "content_type": "application/pdf",
        "data_base64": "JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjg4IDQzMl0+PmVuZG9iagp0cmFpbGVyPDwvUm9vdCAxIDAgUj4+CiUlRU9GCg=="
      },
      "error": null,
      "notices": [],
      "created_at": "2026-09-03T10:12:04Z",
      "updated_at": "2026-09-03T10:12:05Z"
    }

    GET/api/v1/labels/{id}/pdf

    The label PDF bytes.

    Auth: Bearer token, scope labels:read.

    Responses: 200 pdf.

    Errors: label_not_ready (404), not_found (404), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503).

    Example: pending PDF

    Response 404

    {
      "error": {
        "code": "label_not_ready",
        "message": "The label is booked but its PDF is not available yet",
        "retryable": true
      }
    }

    GET/api/v1/labels?reference={reference}

    EirPost's own record for a reference: the authoritative status a client's janitor keys on.

    Auth: Bearer token, scope labels:read.

    References are unique per account, so the answer names exactly one label or none. See the reference action table for what each status means for a parked shipment.

    Responses: 200 ReferenceStatus.

    Errors: bad_request (400), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503).

    Example: unknown reference

    Response 200

    {
      "reference": "ORD-9999-1725350000-zz99",
      "status": "none",
      "label": null
    }

    Example: pending confirmation

    Response 200

    {
      "reference": "ORD-1160-1725350300-ef56",
      "status": "pending_confirmation",
      "label": {
        "id": 8126,
        "status": "pending_confirmation",
        "test": false,
        "label_pending": false,
        "reference": "ORD-1160-1725350300-ef56",
        "order_reference": "#1160",
        "price_cents": 690,
        "rate_tier": "discounted",
        "band": "0.5",
        "pricing_key": "IE",
        "balance_after_cents": null,
        "tracking": null,
        "label": null,
        "error": null,
        "notices": [],
        "created_at": "2026-09-03T10:16:04Z",
        "updated_at": "2026-09-03T10:16:05Z"
      }
    }

    GET/api/v1/tracking?identifiers={a,b}

    Tracking events for up to 100 identifiers (system, shipper or last-mile tracking numbers), normalised.

    Auth: Bearer token, scope tracking:read.

    Responses: 200 TrackingResponse.

    Errors: bad_request (400), unauthenticated (401), connection_revoked (401), insufficient_scope (403), rate_limited (429), maintenance (503).

    Example: one parcel

    Response 200

    {
      "events": [
        {
          "id": "PARCEL_CREATED-2026-09-03T10:12:05.000Z-AIRSTUB1",
          "code": "PARCEL_CREATED",
          "description": "Parcel created",
          "location": null,
          "timestamp": "2026-09-03T10:12:05.000Z",
          "identifiers": {
            "system_tracking_number": "AIRSTUB1",
            "shipper_tracking_number": "LX000000001IE",
            "last_mile_carrier_tracking_number": "LX000000001IE"
          }
        }
      ]
    }

    GET/api/v1/rates

    The shared rates file, so eirlink.ie/eirpost and the EirPost pages show the same numbers. No authentication.

    Auth: no authentication.

    Cached for an hour by callers. Answers 503 maintenance while BALANCE_FEATURE is off, like every v1 endpoint; callers fall back to their static copy.

    Responses: 200 Rates.

    Errors: rate_limited (429), maintenance (503).

    Example: rates

    Response 200

    {
      "rate_version": "2026-09-03",
      "max_weight_kg": 2,
      "bands": [
        "0.5",
        "1",
        "1.5",
        "2"
      ],
      "lanes": [
        {
          "pricing_key": "IE",
          "name": "Ireland",
          "discounted_cents": {
            "1": 790,
            "2": 990,
            "0.5": 690,
            "1.5": 890
          },
          "retail_cents": {
            "1": 1200,
            "2": 1200,
            "0.5": 1000,
            "1.5": 1200
          }
        }
      ]
    }

    GET/api/v1/schema

    This contract as JSON (definitions, endpoints, error codes, tables), for the /developers page and client generators.

    Auth: no authentication.

    Responses: 200 see the summary.

    Errors: rate_limited (429), maintenance (503).

    Definitions used here

    Account

    FieldTypeRequiredNotes
    emailstringyes
    planstringyesone of free, standard, plus, pro
    rate_tierstringyesdiscounted for Plus and Pro, and for free accounts with free uses left; retail otherwise.. one of discounted, retail
    free_uses_remainingintegeryes
    balance_centsintegeryes
    held_centsintegeryes
    available_centsintegeryesbalance_cents minus held_cents.
    blockedbooleanyes
    blocked_reasonstring, nullableyes
    testbooleanyesTrue when this token creates test labels.
    catalogueobjectyes
    catalogue.max_weight_kgnumberyes
    catalogue.pricing_keysarray of stringyesEvery priceable destination: ISO codes plus GB-NI and GB-GB.
    noticesarray of Noticeyes
    rate_versionstringyespattern ^\d{4}-\d{2}-\d{2}(\.\d+)?$

    AcknowledgementResponse

    FieldTypeRequiredNotes
    acknowledgedbooleanyesone of true

    BatchCreate

    FieldTypeRequiredNotes
    labelsarray of LabelCreateyesReferences must be distinct within the batch and unused by the account.. min items 1, max items 100

    BatchResponse

    The state of every child of a batch. Executable invariants: results has exactly label_count entries with unique, contiguous indexes from 0, each result status equals its label status, and every label id and reference appears once. Implementations fill results from the stored children and their tests compare the returned ids with the persisted rows.

    FieldTypeRequiredNotes
    batch_idintegeryes
    label_countintegeryesThe number of labels in the batch as stored; results always carries exactly this many entries.. min 1
    total_centsintegeryes
    balance_after_centsinteger, nullableyesNull while any child hold is still open or for test batches.
    resultsarray of objectyesmin items 1
    results[].indexintegeryes
    results[].statusstringyesone of requested, dispatching, pending_confirmation, created, failed
    results[].labelLabel or LabelPendingyesLabel for created, failed, requested and dispatching (the last two only on a replay while the first request is still running); LabelPending for pending_confirmation.

    Checked by the validator after the structure:

    • results are complete: exactly label_count entries with unique contiguous indexes from 0
    • result status matches its label
    • every result carries a distinct label (id and reference)

    Destination

    FieldTypeRequiredNotes
    namestringyesmin length 1, max length 50
    companystring, nullablenomax length 50
    address1stringyesmin length 1, max length 128
    address2string, nullablenomax length 64
    citystringyesmin length 1, max length 64
    statestring, nullablenomax length 50
    zipstring, nullablenomax length 20
    countrystringyesMust be in the catalogue (GET /v1/account). GB is priced as GB-NI or GB-GB, see region.. pattern ^[A-Z]{2}$
    regionstring, nullablenoPricing region, allowed only when country is GB (validation_failed otherwise). When absent, a BT postcode is GB-NI and any other GB postcode is GB-GB.. one of GB-NI, GB-GB
    phonestring, nullablenomax length 20, pattern ^[^@]*$
    emailstring, nullablenomax length 255

    Item

    FieldTypeRequiredNotes
    descriptionstringyesmin length 1, max length 255
    quantityintegeryesmin 1, max 1000
    unit_valuenumberyesmin 0, max 99999
    weight_kgnumber, nullablenogreater than 0, max 2
    hs_codestring, nullablenoFewer than 6 characters falls back to the generic code, exactly as the site does today.. max length 16
    country_of_originstring, nullablenopattern ^[A-Z]{2}$
    skustring, nullablenomax length 64
    product_urlstring, nullablenomax length 255
    image_urlstring, nullablenomax length 255

    Label

    One label. Executable invariants: status created has a non-empty tracking.shipper and either a label document with label_pending false or label null with label_pending true; error is null unless status is failed; status failed has a non-null error and null tracking and label; requested, dispatching and pending_confirmation have tracking, label and error null and label_pending false; a test label has price_cents 0 and balance_after_cents null; a live label has price_cents above 0; balance_after_cents is non-null exactly for created live labels.

    FieldTypeRequiredNotes
    idintegeryes
    statusstringyesone of requested, dispatching, pending_confirmation, created, failed
    testbooleanyes
    label_pendingbooleanyesTrue when An Post booked the parcel but no PDF is held yet; GET /v1/labels/{id}/pdf answers label_not_ready until it is.
    referencestringyes
    order_referencestringyes
    price_centsintegeryesThe frozen price. 0 for test labels.
    rate_tierstringyesone of discounted, retail, free_use
    bandstringyesone of 0.5, 1, 1.5, 2
    pricing_keystringyes
    balance_after_centsinteger, nullableyesBalance after settlement; null while the hold is open, after a release, or for test labels.
    trackingTracking, nullableyes
    labelLabelDocument, nullableyesPresent on the create response and GET /v1/labels/{id} when the PDF is held; null otherwise.
    errorLabelError, nullableyes
    noticesarray of Noticeyes
    created_atstringyes
    updated_atstringyes

    Checked by the validator after the structure:

    • created has a shipper tracking id and a label or label_pending
    • failed has an error and nothing else
    • in-flight states carry nothing
    • error only on failed
    • test labels cost nothing
    • balance_after exactly when settled

    LabelCreate

    FieldTypeRequiredNotes
    referencestringyesRecorded on the label and returned on every response about it. Unique per account across every label ever created (batch children included); a fresh attempt needs a fresh reference. The shipper reference sent to the carrier is derived from this one and is unique across every account, so two merchants may use the same reference without either one reaching the other’s parcel. For POST /v1/labels it must equal the Idempotency-Key header.. pattern ^[A-Za-z0-9._:#/-]{1,128}$
    order_referencestringyesmin length 1, max length 128
    descriptionstringyesmin length 1, max length 255
    delivery_instructionstring, nullablenomax length 255
    packagePackageyes
    senderSenderyesThe address printed as origin and return address. Occupies exactly the fields a saved site return address occupies. The company line on the label always reads Rofsen whatever company_name is supplied; the supplied value is stored for display only. country must be IE.
    destinationDestinationyes
    itemsarray of Itemyesmin items 1, max items 50
    optionsOptions, nullableno

    LabelDocument

    The label PDF. Executable invariant: data_base64 is base64 of a structurally sound PDF (header, an object, a cross-reference, %%EOF). The server parses every label with pdf-lib before storing it; a document that fails to parse is never returned as a label: the label is created with label null and label_pending true and the PDF is fetched again by tracking id.

    FieldTypeRequiredNotes
    content_typestringyesone of application/pdf
    data_base64stringyesmin length 8, pattern ^[A-Za-z0-9+/]+={0,2}$

    Checked by the validator after the structure:

    • data_base64 decodes to a PDF

    LabelError

    FieldTypeRequiredNotes
    codestringyesone of validation_failed, carrier_failed, never_dispatched, not_created
    messagestringyes
    field_errorsobject, nullableno

    LabelPending

    The compact 202 body: exactly these fields, so a pending entry can never carry tracking, a document or a settled balance.

    FieldTypeRequiredNotes
    idintegeryes
    statusstringyesone of pending_confirmation
    testbooleanyes
    referencestringyes
    price_centsintegeryesHeld, not yet charged (0 for test labels).
    messagestringyes
    retry_after_secondsintegeryes
    noticesarray of Noticeyes

    Checked by the validator after the structure:

    • test pending labels hold nothing, live ones hold their frozen price

    Notice

    Identity is (code, occasion). Shown until acknowledged.

    FieldTypeRequiredNotes
    codestringyesone of weight_accuracy
    occasionstringyesone of signup, third_batch
    textstringyes

    NoticeAck

    FieldTypeRequiredNotes
    codestringyesone of weight_accuracy
    occasionstringyesone of signup, third_batch

    Options

    FieldTypeRequiredNotes
    customs_categorystring, nullablenoDefaults to sale_of_goods.. one of sale_of_goods, gift, documents, commercial_sample, returned_goods
    recipient_vat_numberstring, nullablenomax length 18

    Package

    FieldTypeRequiredNotes
    weight_kgnumberyesDeclared weight. Above 2 is validation_failed. Bands: up to 0.5, 1, 1.5, 2.. greater than 0, max 2
    valuenumberyesmin 0, max 99999
    currencystring, nullablenoone of EUR
    dimensions_cmobject, nullablenoDefaults to 15 x 15 x 10.
    dimensions_cm.lengthintegeryesmin 1, max 300
    dimensions_cm.widthintegeryesmin 1, max 300
    dimensions_cm.heightintegeryesmin 1, max 300

    QuoteParcel

    FieldTypeRequiredNotes
    countrystringyespattern ^[A-Z]{2}$
    postcodestring, nullablenomax length 20
    regionstring, nullablenoAllowed only when country is GB.. one of GB-NI, GB-GB
    weight_kgnumberyesgreater than 0, max 2

    QuoteRequest

    FieldTypeRequiredNotes
    parcelsarray of QuoteParcelyesmin items 1, max items 100

    QuoteResponse

    FieldTypeRequiredNotes
    parcelsarray of objectyes
    parcels[].price_centsintegeryes
    parcels[].rate_tierstringyesone of discounted, retail, free_use
    parcels[].bandstringyesone of 0.5, 1, 1.5, 2
    parcels[].pricing_keystringyes
    total_centsintegeryes
    available_centsintegeryes
    shortfall_centsintegeryes0 when the balance covers the total.
    testbooleanyesTrue for a test token: the prices are what live labels would cost; test labels charge nothing.
    rate_versionstringyespattern ^\d{4}-\d{2}-\d{2}(\.\d+)?$
    expires_atstringyes15 minutes after the quote. Informational: create always reprices and the create price is charged.

    Rates

    FieldTypeRequiredNotes
    rate_versionstringyespattern ^\d{4}-\d{2}-\d{2}(\.\d+)?$
    max_weight_kgnumberyes
    bandsarray of stringyes
    lanesarray of objectyes
    lanes[].pricing_keystringyes
    lanes[].namestringyes
    lanes[].discounted_centsobjectyes
    lanes[].discounted_cents.1integeryes
    lanes[].discounted_cents.2integeryes
    lanes[].discounted_cents.0.5integeryes
    lanes[].discounted_cents.1.5integeryes
    lanes[].retail_centsobjectyes
    lanes[].retail_cents.1integeryes
    lanes[].retail_cents.2integeryes
    lanes[].retail_cents.0.5integeryes
    lanes[].retail_cents.1.5integeryes

    ReferenceStatus

    FieldTypeRequiredNotes
    referencestringyes
    statusstringyesone of none, requested, dispatching, pending_confirmation, created, failed
    labelLabel, nullableyesNull for none.

    Checked by the validator after the structure:

    • none has no label and any other status is the label's status

    Sender

    The address printed as origin and return address. Occupies exactly the fields a saved site return address occupies. The company line on the label always reads Rofsen whatever company_name is supplied; the supplied value is stored for display only. country must be IE.

    FieldTypeRequiredNotes
    namestringyesmin length 1, max length 50
    company_namestring, nullablenomax length 50
    address_line_1stringyesmin length 1, max length 128
    address_line_2string, nullablenomax length 64
    address_line_3string, nullablenomax length 64
    house_numberstring, nullablenomax length 16
    citystringyesmin length 1, max length 64
    statestring, nullablenomax length 50
    zipstring, nullablenomax length 20
    countrystringyesEvery EirPost label is posted from Ireland.. one of IE
    phonestring, nullablenomax length 20, pattern ^[^@]*$
    emailstring, nullablenomax length 255
    vatstring, nullablenomax length 18
    iossstring, nullablenomax length 12
    eoristring, nullablenomax length 17

    Tracking

    FieldTypeRequiredNotes
    systemstring, nullableyes
    shipperstring, nullableyes
    last_milestring, nullableyes

    TrackingEvent

    FieldTypeRequiredNotes
    idstringyes
    codestringyes
    descriptionstringyes
    locationstring, nullableyes
    timestampstringyes
    identifiersobjectyes
    identifiers.system_tracking_numberstring, nullableno
    identifiers.shipper_tracking_numberstring, nullableno
    identifiers.last_mile_carrier_tracking_numberstring, nullableno

    TrackingResponse

    FieldTypeRequiredNotes
    eventsarray of TrackingEventyes

    Connect handshake (EirPost)

    POST/api/v1/connect/exchange

    Exchange a single-use code for the connection token. Idempotent per code inside the exchange window.

    Auth: client_id and client_secret in the body.

    The first call consumes the code (compare-and-set on used_at), checks client_id, the secret and the exact redirect_uri, mints the token, activates the pending connection and returns the token. A repeat with the same code, client and redirect_uri inside exchange_expires_at (10 minutes) returns the same token from the encrypted copy. After the window, or after ack, the copy is erased by the sweeper and the token can never be retrieved again. Activating a connection revokes any other active EirLink connection on the same account in the same transaction.

    Request body: ConnectExchangeRequest

    FieldTypeRequiredNotes
    codestringyesmin length 16, max length 128
    client_idstringyesone of eirlink
    client_secretstringyesmin length 16
    redirect_uristringyesmin length 8, max length 512

    Responses: 200 ConnectExchangeResponse.

    Errors: invalid_client (401), invalid_grant (400), validation_failed (422), rate_limited (429), maintenance (503).

    Example: first exchange

    Request body

    {
      "code": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
      "client_id": "eirlink",
      "client_secret": "local-client-secret-0000000000000000",
      "redirect_uri": "http://127.0.0.1:4000/api/eirpost/connect/callback"
    }

    Response 200

    {
      "connection_id": 17,
      "token": "epc_live_k7QzsampleTOKENsampleTOKENsampleTOKEN0000",
      "test": false,
      "scopes": [
        "labels:create",
        "labels:read",
        "balance:read",
        "tracking:read"
      ],
      "account": {
        "email": "merchant@example.test"
      },
      "exchange_expires_at": "2026-09-03T10:22:04Z"
    }

    Example: used code from another client

    Request body

    {
      "code": "deadbeef00deadbeef00deadbeef00deadbeef00",
      "client_id": "eirlink",
      "client_secret": "local-client-secret-0000000000000000",
      "redirect_uri": "http://127.0.0.1:4000/api/eirpost/connect/callback"
    }

    Response 400

    {
      "error": {
        "code": "invalid_grant",
        "message": "This code is not valid",
        "retryable": false
      }
    }

    POST/api/v1/connect/ack

    EirLink confirms it stored the token; EirPost erases its encrypted copy.

    Auth: client_id and client_secret in the body.

    Request body: ConnectAckRequest

    FieldTypeRequiredNotes
    connection_idintegeryes
    codestringyesmin length 16, max length 128
    client_idstringyesone of eirlink
    client_secretstringyesmin length 16

    Responses: 200 AcknowledgementResponse.

    Errors: invalid_client (401), invalid_grant (400), validation_failed (422), rate_limited (429), maintenance (503).

    Example: ack

    Request body

    {
      "connection_id": 17,
      "code": "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00",
      "client_id": "eirlink",
      "client_secret": "local-client-secret-0000000000000000"
    }

    Response 200

    {
      "acknowledged": true
    }

    POST/api/v1/connect/revoke

    Revoke the token making the call. Idempotent: a token that is already revoked answers 200 with already_revoked true (this is the one endpoint that accepts a revoked token). Labels whose hold committed before the revoke still finish.

    Auth: Bearer token (any scope).

    Responses: 200 RevokeResponse.

    Errors: unauthenticated (401), rate_limited (429), maintenance (503).

    Example: revoke

    Response 200

    {
      "revoked": true,
      "already_revoked": false
    }

    Definitions used here

    AcknowledgementResponse

    FieldTypeRequiredNotes
    acknowledgedbooleanyesone of true

    ConnectAckRequest

    FieldTypeRequiredNotes
    connection_idintegeryes
    codestringyesmin length 16, max length 128
    client_idstringyesone of eirlink
    client_secretstringyesmin length 16

    ConnectExchangeRequest

    FieldTypeRequiredNotes
    codestringyesmin length 16, max length 128
    client_idstringyesone of eirlink
    client_secretstringyesmin length 16
    redirect_uristringyesmin length 8, max length 512

    ConnectExchangeResponse

    FieldTypeRequiredNotes
    connection_idintegeryes
    tokenstringyesepc_live_ bearer token. Returned again for the same code, client and redirect_uri inside exchange_expires_at, then never; the ten-minute exchange window governs that replay rather than the five-minute life of the code itself, so a lost answer is still recoverable after the code has expired.
    testbooleanyesAlways false: EirLink connections are live. Test labels come from epk_test_ keys.. one of false
    scopesarray of stringyes
    accountobjectyes
    account.emailstringyes
    exchange_expires_atstringyes

    RevokeResponse

    FieldTypeRequiredNotes
    revokedbooleanyes
    already_revokedbooleanyes

    EirPost dashboard (session)

    GET/api/balance

    Balance page data for an active EirLink-connected account. 503 maintenance when the rollout does not permit the user or no EirLink connection is active.

    Auth: EirPost dashboard session cookie.

    Responses: 200 SiteBalance.

    Errors: unauthenticated (401), maintenance (503).

    Example: plus account

    Response 200

    {
      "balance_cents": 4410,
      "held_cents": 0,
      "available_cents": 4410,
      "blocked": false,
      "blocked_reason": null,
      "currency": "EUR",
      "presets_cents": [
        1000,
        2500,
        5000,
        10000,
        50000
      ],
      "min_topup_cents": 1000,
      "new_account_guard": {
        "active": false,
        "max_cents": 10000,
        "daily_cap_cents": 20000,
        "daily_remaining_cents": 20000
      },
      "has_successful_label": true,
      "rate_tier": "discounted",
      "free_uses_remaining": 0,
      "notices": [],
      "pending_confirmations": []
    }

    GET/api/balance/ledger?limit={n}&before={id}

    Ledger entries for an active EirLink-connected account, newest first (limit 1 to 100, default 50).

    Auth: EirPost dashboard session cookie.

    Responses: 200 LedgerPage.

    Errors: unauthenticated (401), maintenance (503).

    Example: page

    Response 200

    {
      "entries": [
        {
          "id": 902,
          "type": "label_debit",
          "amount_cents": -800,
          "balance_after_cents": 3610,
          "label_id": 8123,
          "label_reference": "ORD-1157-1725350000-ab12",
          "note": null,
          "created_at": "2026-09-03T10:12:05Z"
        },
        {
          "id": 901,
          "type": "top_up",
          "amount_cents": 4410,
          "balance_after_cents": 4410,
          "label_id": null,
          "label_reference": null,
          "note": null,
          "created_at": "2026-09-03T09:50:00Z"
        }
      ],
      "next_before": null
    }

    POST/api/balance/checkout

    Start a Stripe Checkout top-up for an active EirLink-connected account.

    Auth: EirPost dashboard session cookie.

    Validates amount_cents >= BALANCE_MIN_TOPUP_CENTS and the new-account guard: while the account has no successful non-test label, amount_cents <= BALANCE_NEW_ACCOUNT_MAX_CENTS and the last 24 hours' paid top-ups plus pending top-ups younger than 30 minutes plus this amount <= BALANCE_NEW_ACCOUNT_DAILY_CAP_CENTS (either set to 0 disables the guard); the check runs with the balance_accounts row locked so concurrent checkouts cannot exceed the cap. Inserts a pending balance_topups row, creates the Checkout Session (mode payment; card, link and revolut_pay; expires_at 30 minutes; line item "EirPost shipping balance top-up"; description "Non-refundable. Used only for EirPost shipping labels."; client_reference_id = topup id; metadata {topup_id, user_id, purpose: balance_topup, amount_cents} on the session and on payment_intent_data; success_url = return_to with ?topup=<id>; cancel_url = /balance). Stores checkout_session_id.

    Request body: TopupCheckoutRequest

    FieldTypeRequiredNotes
    amount_centsintegeryesValidated against BALANCE_MIN_TOPUP_CENTS and the new-account guard on the server.. min 100, max 1000000
    return_tostringyesmin length 1, max length 512, pattern ^/(?![/\\])[^\s\\]*$

    Responses: 200 TopupCheckoutResponse.

    Errors: validation_failed (422), amount_too_low (400), new_account_limit (400), account_blocked (423), unauthenticated (401), maintenance (503).

    Example: top up €25

    Request body

    {
      "amount_cents": 2500,
      "return_to": "/balance"
    }

    Response 200

    {
      "url": "https://checkout.stripe.com/c/pay/cs_test_example",
      "topup_id": 310,
      "expires_at": "2026-09-03T10:42:04Z"
    }

    Example: new account over the limit

    Request body

    {
      "amount_cents": 50000,
      "return_to": "/balance"
    }

    Response 400

    {
      "error": {
        "code": "new_account_limit",
        "message": "Up to €100 until your first label is created",
        "retryable": false,
        "max_cents": 10000,
        "daily_remaining_cents": 20000
      }
    }

    GET/api/balance/topups/{id}

    Poll an active EirLink-connected account's top-up after Checkout returns.

    Auth: EirPost dashboard session cookie.

    Responses: 200 Topup.

    Errors: unauthenticated (401), not_found (404), maintenance (503).

    Example: credited

    Response 200

    {
      "id": 310,
      "amount_cents": 2500,
      "status": "paid",
      "credited": true,
      "created_at": "2026-09-03T10:12:04Z",
      "paid_at": "2026-09-03T10:13:00Z"
    }

    POST/api/balance/pay

    For an active EirLink-connected account, pay for a site order from the balance: single checkout, bulk CSV and Etsy all use it.

    Auth: EirPost dashboard session cookie.

    The card path keeps POST /api/get-bulk-labels-after-payment untouched; this is the balance twin of it with the same result shape. The server derives the user from the session, prices every parcel from the shared rates file (client prices are ignored), validates like the API (422 with field_errors keyed parcels.<index>.<field>), opens one child hold per parcel in a batch (all-or-nothing availability, 402 before anything is held), dispatches sequentially with progress through the existing GET /api/check-bulk-progress/{order_id}, and inserts the orders mirror row through the outbox. order_id (at most 100 characters) is the idempotency key: a repeat with the same order_id and body replays the current state (children may be requested or dispatching while the first request runs) without a second hold; a different body is 409 idempotency_conflict, resolved through GET /api/balance/pay?order_id={order_id}. Child references are <order_id>-<index>-<8 random characters> (at most 113 characters), unique per account. Never a card, never a voucher, never a refund.

    Request body: BalancePayRequest

    FieldTypeRequiredNotes
    order_idstringyesThe site order reference (at most 100 characters). It is the idempotency key: a repeat with the same order_id and body replays the stored result; a different body is idempotency_conflict.. pattern ^[A-Za-z0-9._:#/-]{1,100}$
    channelstringyesone of site, bulk, etsy
    parcelsarray of SiteParcelyesmin items 1, max items 100
    user_namestring, nullablenomax length 120

    Responses: 200 BalancePayResponse.

    Errors: validation_failed (422), insufficient_balance (402), account_blocked (423), idempotency_conflict (409), unauthenticated (401), maintenance (503), internal_error (500).

    Example: one domestic parcel

    Request body

    {
      "order_id": "EP-20260903-0001",
      "channel": "site",
      "parcels": [
        {
          "id": "1",
          "recipientName": "Sample Buyer",
          "address1": "4 Example Lane",
          "address2": null,
          "city": "Cork",
          "state": "Co. Cork",
          "postalCode": "T12 AB00",
          "country": "IE",
          "phoneNumber": null,
          "email": null,
          "weight": "0.4",
          "itemValue": "12",
          "itemDescription": "Candle",
          "hsCode": "340600"
        }
      ],
      "user_name": "Sample Sender"
    }

    Response 200

    {
      "success": true,
      "order_id": "EP-20260903-0001",
      "batch_id": 42,
      "parcel_count": 1,
      "total_cents": 690,
      "balance_after_cents": 3720,
      "results": [
        {
          "parcelId": "EP-20260903-0001-parcel-1",
          "recipientName": "Sample Buyer",
          "status": "created",
          "label_pending": false,
          "label_id": 8124,
          "filename": "shipping-label-EP-20260903-0001-parcel-1.pdf",
          "contentType": "application/pdf",
          "labelBuffer": "JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjg4IDQzMl0+PmVuZG9iagp0cmFpbGVyPDwvUm9vdCAxIDAgUj4+CiUlRU9GCg==",
          "trackingNumber": "CB000000002IE",
          "error": null
        }
      ],
      "trackingNumbers": [
        "CB000000002IE"
      ],
      "free_uses_remaining": 0
    }

    Example: short

    Request body

    {
      "order_id": "EP-20260903-0002",
      "channel": "bulk",
      "parcels": [
        {
          "id": "1",
          "recipientName": "Sample Buyer",
          "address1": "3 Example Avenue",
          "address2": null,
          "city": "Portland",
          "state": "OR",
          "postalCode": "97201",
          "country": "US",
          "phoneNumber": "5035550100",
          "email": null,
          "weight": "1.2",
          "itemValue": "48",
          "itemDescription": "Wool scarf",
          "hsCode": "621420"
        }
      ]
    }

    Response 402

    {
      "error": {
        "code": "insufficient_balance",
        "message": "€24.00 needed, €10.00 available",
        "retryable": false,
        "required_cents": 2400,
        "available_cents": 1000,
        "shortfall_cents": 1400,
        "top_up_url": "/balance"
      }
    }

    GET/api/balance/pay?order_id={order_id}

    The current state of an active EirLink-connected account's balance payment by order id (the lookup after a conflict or a lost response). Only the owning session.

    Auth: EirPost dashboard session cookie.

    order_id is a percent-encoded query parameter (encodeURIComponent), never a path segment: ids containing /, # or % must round-trip, which the implementation tests.

    Responses: 200 BalancePayResponse.

    Errors: unauthenticated (401), not_found (404), maintenance (503).

    GET/api/check-bulk-progress/{order_id}

    Existing endpoint, unchanged: the in-memory progress record for an order being processed, keyed by the order id. The balance path writes the same record and serves it only to the owning session (404 not_found for anyone else); legacy card orders keep the existing public behaviour, because their order ids are a timestamp plus four digits and were never a secret.

    Auth: no authentication for legacy orders; EirPost dashboard session cookie of the account that owns the record for balance orders.

    States: status processing with currentStage queued, generating, downloading, saved, merging or emailing; then status completed with progress 100; 404 not_found when no record exists (before processing starts or five minutes after completion, when the record is dropped). The terminal result comes from the payment response or GET /api/balance/pay?order_id={order_id}, never from this record.

    Responses: 200 BulkProgress.

    Errors: not_found (404).

    Example: in progress

    Response 200

    {
      "progress": 50,
      "currentParcel": 1,
      "totalParcels": 2,
      "completedParcels": 0,
      "currentStage": "generating",
      "currentRecipient": "Sample Buyer",
      "parcelStatuses": [],
      "message": "Generating label for Sample Buyer...",
      "status": "processing",
      "trackingNumbers": []
    }

    GET/api/connections

    Connections page data.

    Auth: EirPost dashboard session cookie.

    Responses: 200 ConnectionList.

    Errors: unauthenticated (401), maintenance (503).

    Example: one connection and one key

    Response 200

    {
      "connections": [
        {
          "id": 17,
          "kind": "eirlink",
          "label": "EirLink · sample.myshopify.com",
          "shop_domain": "sample.myshopify.com",
          "client_id": "eirlink",
          "token_hint": "k7Qz",
          "scopes": [
            "labels:create",
            "labels:read",
            "balance:read",
            "tracking:read"
          ],
          "is_test": false,
          "status": "active",
          "last_used_at": "2026-09-03T10:12:04Z",
          "created_at": "2026-09-03T09:00:00Z",
          "activated_at": "2026-09-03T09:00:10Z",
          "revoked_at": null
        },
        {
          "id": 18,
          "kind": "api_key",
          "label": "Warehouse script",
          "shop_domain": null,
          "client_id": null,
          "token_hint": "p2Lm",
          "scopes": [
            "labels:create",
            "labels:read",
            "balance:read",
            "tracking:read"
          ],
          "is_test": true,
          "status": "active",
          "last_used_at": null,
          "created_at": "2026-09-03T09:30:00Z",
          "activated_at": "2026-09-03T09:30:00Z",
          "revoked_at": null
        }
      ]
    }

    POST/api/connections/keys

    Create a manual API key; the token is shown once.

    Auth: EirPost dashboard session cookie.

    Request body: CreateKeyRequest

    FieldTypeRequiredNotes
    labelstringyesmin length 1, max length 60
    testbooleanyes

    Responses: 201 CreateKeyResponse.

    Errors: unauthenticated (401), validation_failed (422), maintenance (503).

    Example: test key

    Request body

    {
      "label": "Warehouse script",
      "test": true
    }

    Response 201

    {
      "id": 18,
      "token": "epk_test_k7QzsampleTOKENsampleTOKENsampleTOKEN0000",
      "token_hint": "p2Lm",
      "scopes": [
        "labels:create",
        "labels:read",
        "balance:read",
        "tracking:read"
      ],
      "is_test": true,
      "created_at": "2026-09-03T09:30:00Z"
    }

    POST/api/connections/{id}/revoke

    Revoke a connection or key. Idempotent. Labels already in progress finish.

    Auth: EirPost dashboard session cookie.

    Responses: 200 RevokeResponse.

    Errors: unauthenticated (401), not_found (404), maintenance (503).

    Example: revoke

    Response 200

    {
      "revoked": true,
      "already_revoked": false
    }

    GET/api/connect/eirlink?state={state}

    The /connect/eirlink page asks its own server for the state claims (server-to-server lookup at EirLink). A signed-in pre-activation response identifies the account by email only and exposes no balance amounts. 404 state_invalid ends with the expired page and a link to eirlink.ie/eirpost/restart carrying shop and return_to.

    Auth: no authentication.

    Responses: 200 ConnectPageState.

    Errors: state_invalid (404), maintenance (503).

    Example: signed in

    Response 200

    {
      "shop_domain": "sample.myshopify.com",
      "return_to": "/dashboard/settings?tab=carriers",
      "expires_at": "2026-09-03T10:42:04Z",
      "signed_in": true,
      "account": {
        "email": "merchant@example.test"
      }
    }

    POST/api/connect/eirlink/keepalive

    While the page is open (every 5 minutes) EirPost renews the state at EirLink.

    Auth: no authentication.

    Request body: ConnectStateBody

    FieldTypeRequiredNotes
    statestringyesmin length 16, max length 128

    Responses: 200 see the summary.

    Errors: validation_failed (422), state_invalid (404).

    Example: renew

    Request body

    {
      "state": "st_sampleSTATEsampleSTATEsampleSTATE00"
    }

    Response 200

    {
      "expires_at": "2026-09-03T10:57:04Z"
    }

    POST/api/connect/eirlink/approve

    Approve the connection once per state.

    Auth: EirPost dashboard session cookie.

    Re-reads the claims from EirLink, checks the redirect URI against CONNECT_REDIRECT_ALLOWLIST exactly, inserts the pending connection and the single-use code (5 minutes, bound to connection, state, client and exact redirect URI; connect_codes.state is unique, so a second approval of the same state is 404 state_invalid and the merchant restarts). EirLink connections are always live; there is no test option. Returns the redirect URL.

    Request body: ConnectStateBody

    FieldTypeRequiredNotes
    statestringyesmin length 16, max length 128

    Responses: 200 ConnectApproveResponse.

    Errors: validation_failed (422), unauthenticated (401), state_invalid (404), account_blocked (423), maintenance (503).

    Example: approve

    Request body

    {
      "state": "st_sampleSTATEsampleSTATEsampleSTATE00"
    }

    Response 200

    {
      "redirect_url": "https://eirlink.ie/api/eirpost/connect/callback?code=c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00&state=st_sampleSTATEsampleSTATEsampleSTATE00"
    }

    POST/api/register

    Existing endpoint plus one optional field.

    Auth: no authentication.

    With connect_state naming a valid, unexpired EirLink state, the location rule (two accounts per IP per day) is bypassed once: the server records the state in connect_signup_bypasses (primary key state) inside the registration transaction, so a second registration with the same state gets no bypass. The bypass is never logged with the email.

    Request body: RegisterConnectFields

    FieldTypeRequiredNotes
    connect_statestring, nullablenomin length 16, max length 128

    Responses: 200 see the summary.

    GET/api/account/notices

    Active notices for the create-label page.

    Auth: EirPost dashboard session cookie.

    weight_accuracy/signup is active from account creation until the first successful non-test label; weight_accuracy/third_batch while the account's completed non-test order count is exactly three (site orders, API batches and single API labels each count as one). Both stop after POST /api/account/notices/dismiss or the bearer ack.

    Responses: 200 NoticeList.

    Errors: unauthenticated (401).

    Example: signup notice

    Response 200

    {
      "notices": [
        {
          "code": "weight_accuracy",
          "occasion": "signup",
          "text": "Parcel weights only need to be broadly right: pick the nearest band for the packed parcel, no need to weigh every one. An Post checks weights, and parcels well under the entered weight can be held or sent back, so when in doubt round up."
        }
      ]
    }

    POST/api/account/notices/dismiss

    Hide one notice for this user.

    Auth: EirPost dashboard session cookie.

    Request body: NoticeAck

    FieldTypeRequiredNotes
    codestringyesone of weight_accuracy
    occasionstringyesone of signup, third_batch

    Responses: 200 AcknowledgementResponse.

    Errors: unauthenticated (401), validation_failed (422).

    Example: dismiss

    Request body

    {
      "code": "weight_accuracy",
      "occasion": "signup"
    }

    Response 200

    {
      "acknowledged": true
    }

    POST/api/redeem-points

    Existing endpoint. For an active EirLink-connected account permitted by the rollout, 1,000 points become a €10 referral_credit; otherwise the existing voucher behaviour is unchanged.

    Auth: EirPost dashboard session cookie.

    The server selects the path before validating the body: the legacy voucher path (no active EirLink connection or rollout access for the user) validates RedeemPointsLegacyRequest, so an empty body stays valid exactly as today; the balance path validates RedeemPointsRequest. The response and the errors follow the same selection: the legacy path answers RedeemPointsLegacyResponse and its existing error shape (RedeemPointsLegacyError), the balance path the contract envelope. Balance path, one transaction: lock the user row, require 1,000 points (400 insufficient_points otherwise), insert the referral_points_ledger row (the redemption record), credit with external_ref referral:<that row id>, record the client idempotency_key (mandatory on the balance path; scope redeem in idempotency_keys) so a repeat returns the original response without redeeming again.

    Body: RedeemPointsRequest on the balance path, RedeemPointsLegacyRequest on the legacy path.

    Request body: RedeemPointsRequest

    FieldTypeRequiredNotes
    idempotency_keystringyesClient-generated per click (a uuid). A repeat with the same key returns the original result and redeems nothing more; without it the request is validation_failed on the balance path (the legacy voucher path ignores it).. min length 8, max length 128

    Responses: 200 RedeemPointsResponse.

    Errors: unauthenticated (401), validation_failed (422), insufficient_points (400).

    Example: legacy voucher (balance feature off for the user)

    Request body

    {}

    Response 200

    {
      "success": true,
      "voucher": {
        "code": "EPSAMPLE01",
        "valueCents": 1000,
        "valueEur": 10,
        "expiresAt": "2026-12-03T00:00:00.000Z",
        "minSpendCents": 1500,
        "minSpendEur": 15
      },
      "pointsRemaining": 250
    }

    Example: legacy not enough points

    Request body

    {}

    Response 400

    {
      "error": "Insufficient points",
      "required": 1000,
      "available": 250
    }

    Example: redeem

    Request body

    {
      "idempotency_key": "redeem-9f1c2d3e4b5a"
    }

    Response 200

    {
      "success": true,
      "credited_cents": 1000,
      "balance_cents": 5410,
      "points_remaining": 250
    }

    Example: not enough points

    Request body

    {
      "idempotency_key": "redeem-9f1c2d3e4b5b"
    }

    Response 400

    {
      "error": {
        "code": "insufficient_points",
        "message": "You need 1,000 points to add €10 to your balance",
        "retryable": false,
        "required_points": 1000,
        "available_points": 250
      }
    }

    GET/api/feature-flags

    Global flags the frontend needs before login: {legacy_shopify_flow: boolean}.

    Auth: no authentication.

    Responses: 200 see the summary.

    Example: flags

    Response 200

    {
      "legacy_shopify_flow": true
    }

    ANY/api/shopify/*

    Legacy Shopify flow. With LEGACY_SHOPIFY_FLOW=off every /api/shopify/* route answers 410 shopify_flow_retired, isShopifyBilledUser reports false and /api/user/billing-source answers stripe; the /shopify-orders and /shopify-create-order pages show the same message. On (the default when missing) nothing changes.

    Auth: no authentication.

    Responses: 410 Error.

    Errors: shopify_flow_retired (410).

    Example: retired

    Response 410

    {
      "error": {
        "code": "shopify_flow_retired",
        "message": "The EirPost Shopify app has been retired. Connect your store through EirLink at https://eirlink.ie/eirpost",
        "retryable": false,
        "link": "https://eirlink.ie/eirpost"
      }
    }

    Definitions used here

    AcknowledgementResponse

    FieldTypeRequiredNotes
    acknowledgedbooleanyesone of true

    BalancePayRequest

    FieldTypeRequiredNotes
    order_idstringyesThe site order reference (at most 100 characters). It is the idempotency key: a repeat with the same order_id and body replays the stored result; a different body is idempotency_conflict.. pattern ^[A-Za-z0-9._:#/-]{1,100}$
    channelstringyesone of site, bulk, etsy
    parcelsarray of SiteParcelyesmin items 1, max items 100
    user_namestring, nullablenomax length 120

    BalancePayResponse

    The state of every parcel of a balance payment. Executable invariant: results has exactly parcel_count entries with unique parcelId and unique label_id values; implementations fill results from the stored rows and their tests compare the returned label ids with the persisted ones.

    FieldTypeRequiredNotes
    successbooleanyes
    order_idstringyes
    batch_idintegeryes
    parcel_countintegeryesThe number of parcels stored for the order; results always carries exactly this many entries.. min 1
    total_centsintegeryes
    balance_after_centsinteger, nullableyes
    resultsarray of BalancePayResultyesmin items 1
    trackingNumbersarray of stringyes
    mergedPdfstring, nullableno
    mergedFilenamestring, nullableno
    free_uses_remainingintegeryes

    Checked by the validator after the structure:

    • results are complete: exactly parcel_count entries with unique parcel ids

    BalancePayResult

    One parcel of a balance payment, the shape the card path returns today plus label_pending. Executable invariants mirror Label: created has a non-empty trackingNumber and either a PDF in labelBuffer with label_pending false or labelBuffer null with label_pending true; error is null unless status is failed; failed has an error and nothing else; requested, dispatching and pending_confirmation carry nothing.

    FieldTypeRequiredNotes
    parcelIdstringyes
    recipientNamestringyes
    statusstringyesrequested and dispatching appear only on a replay while the first request is still running.. one of requested, dispatching, pending_confirmation, created, failed
    label_idintegeryes
    filenamestring, nullableyes
    contentTypestring, nullableyes
    labelBufferstring, nullableyesBase64 PDF, exactly as the card path returns it; null while label_pending.. pattern ^[A-Za-z0-9+/]+={0,2}$
    trackingNumberstring, nullableyes
    errorstring, nullableyes
    label_pendingbooleanyesTrue when An Post booked the parcel but no PDF is held yet; the tracking number is shown and the PDF follows.

    Checked by the validator after the structure:

    • created carries a tracking number and a PDF or label_pending
    • failed carries an error and nothing else
    • error only on failed
    • in-flight results carry nothing

    BulkProgress

    The existing progress record the card path writes and the balance path reuses.

    FieldTypeRequiredNotes
    progressintegeryesmin 0, max 100
    currentParcelintegeryes
    totalParcelsintegeryes
    completedParcelsinteger, nullableno
    currentStagestring, nullablenoone of queued, generating, downloading, saved, merging, emailing, completed
    currentRecipientstring, nullableno
    parcelStatusesarray of object, nullableno
    messagestringyes
    statusstringyesone of processing, completed, error, not_found
    trackingNumbersarray of string, nullableno

    ConnectApproveResponse

    FieldTypeRequiredNotes
    redirect_urlstringyes

    Connection

    FieldTypeRequiredNotes
    idintegeryes
    kindstringyesone of eirlink, api_key
    labelstring, nullableyes
    shop_domainstring, nullableno
    client_idstring, nullableyes
    token_hintstring, nullableyes
    scopesarray of stringyes
    is_testbooleanyes
    statusstringyesone of pending, active, revoked
    last_used_atstring, nullableyes
    created_atstringyes
    activated_atstring, nullableyes
    revoked_atstring, nullableyes

    ConnectionList

    FieldTypeRequiredNotes
    connectionsarray of Connectionyes

    ConnectPageState

    FieldTypeRequiredNotes
    shop_domainstring, nullableyes
    return_tostring, nullableyes
    expires_atstringyes
    signed_inbooleanyes
    accountobject, nullableyes
    account.emailstringyes

    ConnectStateBody

    FieldTypeRequiredNotes
    statestringyesmin length 16, max length 128

    CreateKeyRequest

    FieldTypeRequiredNotes
    labelstringyesmin length 1, max length 60
    testbooleanyes

    CreateKeyResponse

    FieldTypeRequiredNotes
    idintegeryes
    tokenstringyesShown once.
    token_hintstringyes
    scopesarray of stringyes
    is_testbooleanyes
    created_atstringyes

    Error

    FieldTypeRequiredNotes
    errorobjectyes
    error.codestringyesone of bad_request, unauthenticated, connection_revoked, insufficient_scope, insufficient_balance, not_found, label_not_ready, idempotency_conflict, reference_in_use, already_credited, validation_failed, account_blocked, rate_limited, amount_too_low, insufficient_points, new_account_limit, invalid_client, invalid_grant, state_invalid, shopify_flow_retired, carrier_failed, maintenance, internal_error
    error.messagestringyes
    error.retryablebooleanyes
    error.field_errorsobject, nullablenoDotted path to a list of messages, e.g. {"destination.zip": ["is required"]}.
    error.required_centsinteger, nullableno
    error.available_centsinteger, nullableno
    error.shortfall_centsinteger, nullableno
    error.top_up_urlstring, nullableno
    error.min_centsinteger, nullableno
    error.max_centsinteger, nullableno
    error.daily_remaining_centsinteger, nullableno
    error.linkstring, nullableno
    error.entryobject, nullableno
    error.required_pointsinteger, nullableno
    error.available_pointsinteger, nullableno

    Checked by the validator after the structure:

    • retryable equals the catalogue value for the code; extras required by the code: insufficient_balance carries required_cents, available_cents, shortfall_cents and top_up_url; validation_failed carries field_errors; already_credited carries entry; amount_too_low carries min_cents; new_account_limit carries max_cents and daily_remaining_cents; shopify_flow_retired carries link; insufficient_points carries required_points and available_points

    LedgerEntry

    FieldTypeRequiredNotes
    idintegeryes
    typestringyesone of top_up, label_debit, weight_adjustment, cancellation_credit, manual_adjustment, chargeback_reversal, chargeback_won, referral_credit
    amount_centsintegeryes
    balance_after_centsintegeryes
    label_idinteger, nullableyes
    label_referencestring, nullableyes
    notestring, nullableyes
    created_atstringyes

    LedgerPage

    FieldTypeRequiredNotes
    entriesarray of LedgerEntryyes
    next_beforeinteger, nullableyes

    Notice

    Identity is (code, occasion). Shown until acknowledged.

    FieldTypeRequiredNotes
    codestringyesone of weight_accuracy
    occasionstringyesone of signup, third_batch
    textstringyes

    NoticeAck

    FieldTypeRequiredNotes
    codestringyesone of weight_accuracy
    occasionstringyesone of signup, third_batch

    NoticeList

    FieldTypeRequiredNotes
    noticesarray of Noticeyes

    RedeemPointsLegacyError

    The existing voucher path error shape, unchanged: a plain error string, with required and available on the insufficient-points case.

    FieldTypeRequiredNotes
    errorstringyes
    requiredinteger, nullableno
    availableinteger, nullableno

    RedeemPointsLegacyRequest

    The body the existing voucher path accepts: empty, or an idempotency_key it ignores. The server selects the path before validating.

    FieldTypeRequiredNotes
    idempotency_keystring, nullablenoAccepted and ignored on the legacy path.. max length 128

    RedeemPointsLegacyResponse

    The existing voucher path response, unchanged.

    FieldTypeRequiredNotes
    successbooleanyesone of true
    voucherobjectyes
    voucher.codestringyes
    voucher.valueCentsintegeryes
    voucher.valueEurnumberyes
    voucher.expiresAtstringyes
    voucher.minSpendCentsintegeryes
    voucher.minSpendEurnumberyes
    pointsRemainingintegeryes

    RedeemPointsRequest

    FieldTypeRequiredNotes
    idempotency_keystringyesClient-generated per click (a uuid). A repeat with the same key returns the original result and redeems nothing more; without it the request is validation_failed on the balance path (the legacy voucher path ignores it).. min length 8, max length 128

    RedeemPointsResponse

    FieldTypeRequiredNotes
    successbooleanyes
    credited_centsintegeryes
    balance_centsintegeryes
    points_remainingintegeryes

    RegisterConnectFields

    The fields the connect page adds to the existing /api/register body (the rest of that body is unchanged).

    FieldTypeRequiredNotes
    connect_statestring, nullablenomin length 16, max length 128

    RevokeResponse

    FieldTypeRequiredNotes
    revokedbooleanyes
    already_revokedbooleanyes

    SiteBalance

    FieldTypeRequiredNotes
    balance_centsintegeryes
    held_centsintegeryes
    available_centsintegeryes
    blockedbooleanyes
    blocked_reasonstring, nullableyes
    currencystringyesone of EUR
    presets_centsarray of integeryes
    min_topup_centsintegeryes
    new_account_guardobjectyes
    new_account_guard.activebooleanyes
    new_account_guard.max_centsintegeryes
    new_account_guard.daily_cap_centsintegeryes
    new_account_guard.daily_remaining_centsintegeryes
    has_successful_labelbooleanyes
    rate_tierstringyesone of discounted, retail
    free_uses_remainingintegeryes
    noticesarray of Noticeyes
    pending_confirmationsarray of objectyes
    pending_confirmations[].label_idintegeryes
    pending_confirmations[].referencestringyes
    pending_confirmations[].price_centsintegeryes
    pending_confirmations[].created_atstringyes

    SiteParcel

    The parcel object Payment.tsx sends today (CreateOrder, bulk CSV and Etsy build the same shape). The client-side price fields, isDiscountedPricing, source, userEmail and userName are accepted and ignored: the server prices every parcel and the session decides the account. Validated like the API before anything is held.

    FieldTypeRequiredNotes
    idstringyesmin length 1, max length 64
    recipientNamestringyesmin length 1, max length 50
    address1stringyesmin length 1, max length 128
    address2string, nullablenomax length 64
    citystringyesmin length 1, max length 64
    statestringyesmax length 50
    postalCodestringyesmax length 40
    countrystringyesMust be in the catalogue.. pattern ^([A-Z]{2}|GB-NI|GB-GB)$
    phoneNumberstring, nullablenomax length 20, pattern ^[^@]*$
    emailstring, nullablenomax length 255
    weightstringyesKilograms as a decimal string, above 0 and at most 2.. pattern ^\d+(\.\d+)?$
    itemValuestringyespattern ^\d+(\.\d+)?$
    itemDescriptionstringyesmin length 1, max length 255
    hsCodestring, nullablenomax length 16
    shopifyobject, nullablenoWriteback metadata {shopDomain, orderId} as today.
    etsyobject, nullablenoWriteback metadata {shopId, receiptId} as today.
    calculatedPricenumber, nullablenoClient display value, ignored.
    retailPricenumber, nullablenoIgnored.
    myPricenumber, nullablenoIgnored.
    originalDiscountedPricenumber, nullablenoIgnored.
    isDiscountedPricingboolean, nullablenoClient display flag, ignored.
    sourcestring, nullablenoWhere the parcel came from (etsy, shopify), accepted and ignored.. max length 32
    userEmailstring, nullablenoIgnored (the session decides the account).. max length 255
    userNamestring, nullablenoIgnored.. max length 120

    Checked by the validator after the structure:

    • weight is a decimal above 0 and at most 2
    • itemValue is a decimal from 0 to 99999

    Topup

    FieldTypeRequiredNotes
    idintegeryes
    amount_centsintegeryes
    statusstringyesone of pending, paid
    creditedbooleanyes
    created_atstringyes
    paid_atstring, nullableyes

    TopupCheckoutRequest

    FieldTypeRequiredNotes
    amount_centsintegeryesValidated against BALANCE_MIN_TOPUP_CENTS and the new-account guard on the server.. min 100, max 1000000
    return_tostringyesmin length 1, max length 512, pattern ^/(?![/\\])[^\s\\]*$

    TopupCheckoutResponse

    FieldTypeRequiredNotes
    urlstringyes
    topup_idintegeryes
    expires_atstringyesThe Checkout Session expires 30 minutes after creation.

    EirPost admin (session, admin)

    GET/api/admin/balances?filter={all|negative|blocked}&q={email}

    Accounts with a balance row: {accounts: [{user_id, email, plan, balance_cents, held_cents, available_cents, blocked, blocked_reason, weight_review_flag, labels_30d, last_label_at}]}.

    Auth: EirPost dashboard session cookie, admin account.

    Responses: 200 see the summary.

    Errors: unauthenticated (401).

    GET/api/admin/balances/{userId}

    One account: {account, ledger: LedgerEntry[], holds[], labels[], topups[], reversals[], connections: Connection[]}. The account body is SiteBalance plus user_id and email, because an admin screen has to say whose account it is showing; SiteBalance itself is unchanged, since a merchant reading their own balance already knows. The five row arrays are the admin screen’s own reading of the tables and are deliberately not part of the contract: nothing outside the admin dashboard may depend on their shape.

    Auth: EirPost dashboard session cookie, admin account.

    Responses: 200 see the summary.

    Errors: unauthenticated (401), not_found (404).

    POST/api/admin/balances/{userId}/adjust

    Manual adjustment with a mandatory note and a client-generated key.

    Auth: EirPost dashboard session cookie, admin account.

    Writes manual_adjustment with actor admin:<email> and external_ref manual:<key>. A negative amount may take the balance negative and blocks the account; a positive one unblocks at zero or above. A repeated key returns the original entry unchanged.

    Request body: AdminAdjustRequest

    FieldTypeRequiredNotes
    amount_centsintegeryesSigned, non-zero.. min -1000000, max 1000000
    notestringyesmin length 3, max length 500
    keystringyesClient-generated idempotency key (a uuid from the admin page). A repeat returns the original entry.. min length 8, max length 128

    Responses: 200 AdminMoneyResponse.

    Errors: unauthenticated (401), not_found (404), validation_failed (422).

    POST/api/admin/balances/{userId}/cancel-credit

    Cancellation credit after ROF approves by email.

    Auth: EirPost dashboard session cookie, admin account.

    The label must belong to {userId} (404 otherwise), be status created and not test (422 otherwise). Credits the full frozen price once per label (cancellation_credit, external_ref cancel:<label_id>, recipient derived from the label); a second call answers 409 already_credited carrying the existing entry and changes nothing.

    Request body: AdminCancelCreditRequest

    FieldTypeRequiredNotes
    label_idintegeryesmin 1
    notestringyesmin length 3, max length 500

    Responses: 200 AdminMoneyResponse.

    Errors: unauthenticated (401), not_found (404), validation_failed (422), already_credited (409).

    GET/api/admin/pending-confirmations

    Labels in pending_confirmation: {labels: [{label_id, user_email, reference, carrier_reference, price_cents, channel, test, dispatched_at, confirmation_attempts, next_confirmation_at, age_hours}]}. `carrier_reference` is the one to search at the carrier: `reference` is the merchant’s own and is unique only per account, so it can name a different parcel on another account.

    Auth: EirPost dashboard session cookie, admin account.

    Responses: 200 see the summary.

    Errors: unauthenticated (401).

    POST/api/admin/labels/{id}/confirm-created

    ROF found the parcel in eCommHub. Finalises as created (settles the hold, one debit), stores the tracking and queues the PDF fetch by exact tracking. Only from pending_confirmation (409 idempotency_conflict otherwise).

    Auth: EirPost dashboard session cookie, admin account.

    Request body: AdminConfirmCreatedRequest

    FieldTypeRequiredNotes
    trackingobjectyes
    tracking.shipperstringyesmin length 6, max length 40
    tracking.systemstring, nullablenomax length 40
    tracking.last_milestring, nullablenomax length 40

    Responses: 200 Label.

    Errors: unauthenticated (401), not_found (404), validation_failed (422), idempotency_conflict (409).

    POST/api/admin/labels/{id}/confirm-not-created

    ROF found no parcel: finalises as failed (releases the hold, restores a reserved free use). Only from pending_confirmation.

    Auth: EirPost dashboard session cookie, admin account.

    Responses: 200 Label.

    Errors: unauthenticated (401), not_found (404), idempotency_conflict (409).

    GET/api/admin/connections

    Every connection and key across accounts with status, last use and label counts: {connections: [Connection plus user_email and labels_total]}.

    Auth: EirPost dashboard session cookie, admin account.

    Responses: 200 see the summary.

    Errors: unauthenticated (401).

    Definitions used here

    AdminAdjustRequest

    FieldTypeRequiredNotes
    amount_centsintegeryesSigned, non-zero.. min -1000000, max 1000000
    notestringyesmin length 3, max length 500
    keystringyesClient-generated idempotency key (a uuid from the admin page). A repeat returns the original entry.. min length 8, max length 128

    Checked by the validator after the structure:

    • amount is non-zero

    AdminCancelCreditRequest

    FieldTypeRequiredNotes
    label_idintegeryesmin 1
    notestringyesmin length 3, max length 500

    AdminConfirmCreatedRequest

    FieldTypeRequiredNotes
    trackingobjectyes
    tracking.shipperstringyesmin length 6, max length 40
    tracking.systemstring, nullablenomax length 40
    tracking.last_milestring, nullablenomax length 40

    AdminMoneyResponse

    FieldTypeRequiredNotes
    entryLedgerEntryyes
    accountSiteBalanceyes

    Label

    One label. Executable invariants: status created has a non-empty tracking.shipper and either a label document with label_pending false or label null with label_pending true; error is null unless status is failed; status failed has a non-null error and null tracking and label; requested, dispatching and pending_confirmation have tracking, label and error null and label_pending false; a test label has price_cents 0 and balance_after_cents null; a live label has price_cents above 0; balance_after_cents is non-null exactly for created live labels.

    FieldTypeRequiredNotes
    idintegeryes
    statusstringyesone of requested, dispatching, pending_confirmation, created, failed
    testbooleanyes
    label_pendingbooleanyesTrue when An Post booked the parcel but no PDF is held yet; GET /v1/labels/{id}/pdf answers label_not_ready until it is.
    referencestringyes
    order_referencestringyes
    price_centsintegeryesThe frozen price. 0 for test labels.
    rate_tierstringyesone of discounted, retail, free_use
    bandstringyesone of 0.5, 1, 1.5, 2
    pricing_keystringyes
    balance_after_centsinteger, nullableyesBalance after settlement; null while the hold is open, after a release, or for test labels.
    trackingTracking, nullableyes
    labelLabelDocument, nullableyesPresent on the create response and GET /v1/labels/{id} when the PDF is held; null otherwise.
    errorLabelError, nullableyes
    noticesarray of Noticeyes
    created_atstringyes
    updated_atstringyes

    Checked by the validator after the structure:

    • created has a shipper tracking id and a label or label_pending
    • failed has an error and nothing else
    • in-flight states carry nothing
    • error only on failed
    • test labels cost nothing
    • balance_after exactly when settled

    LabelDocument

    The label PDF. Executable invariant: data_base64 is base64 of a structurally sound PDF (header, an object, a cross-reference, %%EOF). The server parses every label with pdf-lib before storing it; a document that fails to parse is never returned as a label: the label is created with label null and label_pending true and the PDF is fetched again by tracking id.

    FieldTypeRequiredNotes
    content_typestringyesone of application/pdf
    data_base64stringyesmin length 8, pattern ^[A-Za-z0-9+/]+={0,2}$

    Checked by the validator after the structure:

    • data_base64 decodes to a PDF

    LabelError

    FieldTypeRequiredNotes
    codestringyesone of validation_failed, carrier_failed, never_dispatched, not_created
    messagestringyes
    field_errorsobject, nullableno

    LedgerEntry

    FieldTypeRequiredNotes
    idintegeryes
    typestringyesone of top_up, label_debit, weight_adjustment, cancellation_credit, manual_adjustment, chargeback_reversal, chargeback_won, referral_credit
    amount_centsintegeryes
    balance_after_centsintegeryes
    label_idinteger, nullableyes
    label_referencestring, nullableyes
    notestring, nullableyes
    created_atstringyes

    Notice

    Identity is (code, occasion). Shown until acknowledged.

    FieldTypeRequiredNotes
    codestringyesone of weight_accuracy
    occasionstringyesone of signup, third_batch
    textstringyes

    SiteBalance

    FieldTypeRequiredNotes
    balance_centsintegeryes
    held_centsintegeryes
    available_centsintegeryes
    blockedbooleanyes
    blocked_reasonstring, nullableyes
    currencystringyesone of EUR
    presets_centsarray of integeryes
    min_topup_centsintegeryes
    new_account_guardobjectyes
    new_account_guard.activebooleanyes
    new_account_guard.max_centsintegeryes
    new_account_guard.daily_cap_centsintegeryes
    new_account_guard.daily_remaining_centsintegeryes
    has_successful_labelbooleanyes
    rate_tierstringyesone of discounted, retail
    free_uses_remainingintegeryes
    noticesarray of Noticeyes
    pending_confirmationsarray of objectyes
    pending_confirmations[].label_idintegeryes
    pending_confirmations[].referencestringyes
    pending_confirmations[].price_centsintegeryes
    pending_confirmations[].created_atstringyes

    Tracking

    FieldTypeRequiredNotes
    systemstring, nullableyes
    shipperstring, nullableyes
    last_milestring, nullableyes

    Stripe webhook (EirPost)

    POST/api/stripe/webhook

    The existing production webhook endpoint, now a two-phase inbox.

    Auth: Stripe-Signature over the raw body.

    Raw body, Stripe-Signature verified against STRIPE_WEBHOOK_SECRET; a bad signature is 400 as today. Phase one commits the event into stripe_events (INSERT ... ON CONFLICT DO NOTHING) before anything else. Phase two claims the row with a two-minute lease and a worker token, runs every applicable handler in its own transaction (checkout.session.completed: balance top-up when metadata.purpose is balance_topup, otherwise the subscription handler; payment_intent.succeeded: balance top-up when metadata.purpose says so; charge.dispute.created and charge.dispute.closed: balance; charge.refunded: referral reversal and balance top-up, each independently; invoice.paid, invoice.payment_failed, customer.subscription.updated, customer.subscription.deleted: subscription), records completion per handler, sets processed_at only when every handler is done. Responses: 200 when every handler completed; 200 for a duplicate delivery of a processed event; 200 when another worker holds the lease (the sweep or the next retry finishes it); 500 when any handler failed (the error is recorded on the row and Stripe retries); a five-minute sweep also retries unprocessed rows whose lease lapsed. Required event types on the endpoint: checkout.session.completed, payment_intent.succeeded, charge.dispute.created, charge.dispute.closed, charge.refunded, plus the existing subscription types.

    Responses: 200 see the summary; 400 see the summary; 500 see the summary.

    Environment

    The switches behind the API, listed so operators and integrators read the same table.

    eirpost

    VariableInitialFinalMeaning
    BALANCE_FEATUREoffonKill switch. off: every v1 endpoint (rates, schema and the handshake included) and every site balance route answer 503 maintenance unless the account behind the session or token is in BALANCE_TEST_USERS; webhooks still record into the inbox and process. Even when permitted by the rollout, signed-in balance reads, top-ups and balance payments require an active EirLink connection. The connect handshake remains available so that connection can be established. Rollback sets off with an empty allow-list, which blocks everyone.
    BALANCE_TEST_USERS(empty)(empty)Comma-separated EirPost user ids for whom the rollout is on while BALANCE_FEATURE is off. Visibility only: no scope bypass, no money bypass, no dispatch-mode change, and no bypass of the active-EirLink requirement on signed-in balance routes; emptied before the flag goes public.
    CARD_PATH_TEST_USERS(empty)(removed)Comma-separated user ids whose card checkout creates An Post test parcels. Removed after the final verification checkout.
    PRICE_ENFORCEMENTshadowenforceshadow logs client-amount mismatches at create-payment-intent; enforce rejects them. Promoted after a clean shadow sample.
    CARD_PATH_ENGINElegacybalance_enginelegacy keeps today's bulk endpoint code; balance_engine routes its An Post call through buildAnPostPayload and the outcome classes. Promoted after the golden tests and the shadow sample are clean.
    LEGACY_SHOPIFY_FLOWonoffMissing means on. off retires the Gadget-era Shopify routes and pages with 410; switched only after the app is unlisted.
    BALANCE_MIN_TOPUP_CENTS10001000
    BALANCE_PRESETS1000,2500,5000,10000,50000same
    BALANCE_NEW_ACCOUNT_MAX_CENTS10000100000 disables the guard.
    BALANCE_NEW_ACCOUNT_DAILY_CAP_CENTS20000200000 disables the guard.
    EIRLINK_CLIENT_IDeirlinkeirlink
    EIRLINK_CLIENT_SECRET(secret)(secret)Shared with EirLink EIRPOST_CLIENT_SECRET.
    EIRLINK_STATE_LOOKUP_URLhttps://eirlink.ie/api/eirpost/connect/statesameBase of the state lookup and renew calls.
    CONNECT_REDIRECT_ALLOWLISThttps://eirlink.ie/api/eirpost/connect/callback,https://www.eirlink.ie/api/eirpost/connect/callbacksame
    MIGRATIONS_DATABASE_URL(secret)(secret)Superuser URL used only by node migrate.js; DATABASE_URL stays the application role.
    RECONCILIATION_TOKEN(secret)(secret)Bearer for the invoice reconciliation import from the Mac.
    TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID(secret)(secret)One-line operational alerts.

    eirlink

    VariableInitialFinalMeaning
    EIRPOST_CARRIER_ENABLEDoffonoff hides the carrier; jobs targeting EIRPOST fail with a clear message; existing rows kept.
    EIRPOST_CARRIER_TEST_USERS(empty)(empty)Comma-separated EirLink user ids who see the carrier while it is off; emptied before the flag goes public.
    EIRPOST_API_BASEhttps://eirpost-production.up.railway.app/api/v1sameServer-to-server base for v1 calls (no browser hop).
    EIRPOST_CLIENT_IDeirlinkeirlink
    EIRPOST_CLIENT_SECRET(secret)(secret)Shared with EirPost EIRLINK_CLIENT_SECRET.
    EIRPOST_CONNECT_URLhttps://www.eirpost.ie/connect/eirlinksameWhere the browser is sent with ?state=.
    NEXT_PUBLIC_EIRPOST_URLhttps://www.eirpost.iesameVercel: base for the public rates fetch and the top-up links.