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
| Field | Type | Required | Notes |
|---|
amount_cents | integer | yes | Validated against BALANCE_MIN_TOPUP_CENTS and the new-account guard on the server.. min 100, max 1000000 |
return_to | string | yes | min 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
| Field | Type | Required | Notes |
|---|
order_id | string | yes | The 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}$ |
channel | string | yes | one of site, bulk, etsy |
parcels | array of SiteParcel | yes | min items 1, max items 100 |
user_name | string, nullable | no | max 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
| Field | Type | Required | Notes |
|---|
label | string | yes | min length 1, max length 60 |
test | boolean | yes | |
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
| Field | Type | Required | Notes |
|---|
state | string | yes | min 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
| Field | Type | Required | Notes |
|---|
state | string | yes | min 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
| Field | Type | Required | Notes |
|---|
connect_state | string, nullable | no | min 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
| Field | Type | Required | Notes |
|---|
code | string | yes | one of weight_accuracy |
occasion | string | yes | one 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
| Field | Type | Required | Notes |
|---|
idempotency_key | string | yes | Client-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"
}
}