Skip to main content

Post DALP events to a ledger

How DALP posts money events to an organisation's Xero ledger, how to connect Xero, the posting records and cases, and the rules a custom subscriber follows.

Overview

DALP posts an organisation's final money events to that organisation's Xero ledger. A background job turns each event the organisation's rule table maps, such as a trade settlement, a fee, a bank rail credit or cash-out, a paid dividend or the reversal of one of these, into a balanced Xero manual journal. It posts each journal once and keeps a posting record of what happened to every event.

The same events reach every webhook endpoint the organisation registers. An organisation that keeps its books in another ledger, or that wants its own posting logic, runs a subscriber that receives the signed feed and follows the rules on this page. For endpoint registration and the signature scheme in full, see Receive DALP webhooks.

How DALP posts to Xero

A job that runs once a minute across the deployment reads the organisation's committed events. The platform writes each committed event in the same database transaction as the change it describes, before any webhook delivery. The job needs no webhook endpoint, so an endpoint that is switched off or failing does not hold posting back.

For each event, the job does four things:

  1. It copies the event into a posting record, from the creation of the organisation's first stored configuration version onwards. Earlier events are never posted.
  2. It decides the event with the rule table of the newest configuration version, as From event to journal describes.
  3. It maps the journal's account keys to the Xero account codes in the configuration, checks the journal against the Xero organisation's base currency, and posts it as one manual journal with status POSTED and no tax.
  4. It records the Xero journal id and the Xero organisation on the posting record.

A journal reaches Xero at most once for each record, across retries, crashes and configuration changes. Each DALP journal carries a narration marker built from the organisation and the event's source_id, and DALP sends Xero an idempotency key built the same way. Before DALP sends a journal again, it looks the marker up in Xero, and it records a journal Xero already accepted instead of posting a second one. Once a request may have reached Xero, every later attempt sends exactly that request to the same Xero organisation, even after a configuration change.

Do not edit or delete the narration of a DALP journal in Xero, because DALP finds its journals by it. A correction is a new journal, never an edit of a posted one.

The job works through one organisation after another and posts one record at a time for each organisation.

Connect Xero

Each organisation connects its own Xero app, so the deployment holds no shared Xero credentials. An organisation administrator who holds the webhook:manage permission connects it while signed in to the Console. An API key cannot connect, read or disconnect Xero.

  1. Create a web app for the organisation in the Xero developer portal, and note its client id and client secret.
  2. From a signed-in Console session, send them to POST /api/v2/accounting/xero/connection.
  3. Add the redirectUri that DALP returns to the Xero app's redirect URIs, exactly as returned. It is the Console address followed by /api/v2/accounting/xero/callback.
  4. Within ten minutes, open authorizeUrl in the same browser and consent at Xero for the Xero organisation that holds the books.
  5. Xero returns the browser to DALP. DALP completes the connection and redirects to /org-settings/accounting?xero=<outcome> on the Console.
  6. Read the connection with GET /api/v2/accounting/xero/connection, then store a posting configuration whose mapping.xeroTenantId is one of its tenantIds.

The connect request carries the Xero app's credentials. The values below are illustrative:

POST /api/v2/accounting/xero/connection
Content-Type: application/json

{
  "clientId": "6F1C9A2B4D7E40A8B3C5D9E1F2A4B6C8",
  "clientSecret": "<the Xero app's client secret>"
}

DALP answers with the Xero consent URL and the redirect URI. It never returns the client secret:

{
  "authorizeUrl": "<Xero consent URL carrying the client id, the redirect URI, the three scopes and a single-use state>",
  "redirectUri": "https://your-instance.example.com/api/v2/accounting/xero/callback"
}

DALP asks Xero for four scopes. offline_access keeps the connection alive, accounting.manualjournals posts and finds manual journals, accounting.settings.read reads the Xero organisation's base currency and its chart of accounts, and accounting.reports.balancesheet.read reads the client-money balance for the bank rail daily reconciliation. A connection made before DALP asked for the balance-sheet scope keeps posting. Connect Xero again to add the scope.

A new connect replaces any connect still waiting for consent. The callback completes only for the signed-in user who started the connect, while that user still holds webhook:manage in the organisation, and each consent URL works once. Opened without a Console session, the callback answers 401 and leaves the consent URL usable until it expires.

Outcome
What happened
connected
DALP stored the grant and the Xero organisations it connected.
denied
The administrator declined at Xero, or no longer holds webhook:manage in the organisation.
invalid-state
The consent URL is unknown, expired or already used, or a different user is signed in.
exchange-failed
Xero refused to exchange the authorisation code, for example because the client secret or the redirect URI does not match the app, or DALP could not read the grant's organisations.
no-organisation
The grant connected no Xero organisation.
tenant-in-use
A Xero organisation in the grant is already connected to another DALP organisation.

On every outcome except connected, DALP discards the client secret it stored for the connect and revokes any token Xero issued. The outcome never carries Xero's own error text.

Connecting again with the same Xero user and the same app replaces the stored token. Connecting with a different Xero user or a different app also revokes the earlier grant at Xero.

Connection status and disconnect

GET /api/v2/accounting/xero/connection reads the stored connection without calling Xero:

{
  "state": "connected",
  "tenantIds": ["8a3f2c71-5d4e-4b9a-a1c6-2e7f9b0d3c58"],
  "lastRefreshedAt": "2026-11-10T09:15:00.000Z"
}

state is connected, needs-reconnect or disconnected. needs-reconnect means Xero refused the stored grant, for example after someone removed the app's connection in Xero. Posting then pauses, and DALP raises a Xero connection needs attention case. Connect again to resume.

While it posts, DALP refreshes the access token about every 30 minutes. It also refreshes a connection that has nothing to post before Xero's 60-day refresh-token expiry. Each refresh rotates the refresh token, and DALP keeps the newest token even when it crashes between Xero's answer and its own write.

DELETE /api/v2/accounting/xero/connection disconnects, and posting stops at once. DALP then revokes the grant at Xero, retrying every minute until Xero confirms, and deletes the token and the client secret only after that. After five unconfirmed attempts it raises a Xero connection needs attention case. An organisation with no connection gets 404 with DALP-20544.

Who can call what

Call
Route
Needs
Connect
POST /api/v2/accounting/xero/connection
A Console session with webhook:manage. Never held for a second approver, because its body carries the client secret.
Read the connection
GET /api/v2/accounting/xero/connection
A Console session with webhook:manage.
Disconnect
DELETE /api/v2/accounting/xero/connection
A fresh Console session with webhook:manage. Held for a second approver while the organisation protects Custody administration changes.
Callback
GET /api/v2/accounting/xero/callback
The Console session of the user who started the connect, still holding webhook:manage.
Store the configuration
PUT /api/v2/accounting/configuration
webhook:manage from a session or an API key. Held for a second approver while the organisation protects Custody administration changes.
Read the configuration
GET /api/v2/accounting/configuration
webhook:manage from a session or an API key.
List the posting records
GET /api/v2/accounting/postings
A Console session with webhook:manage.
Replay a posting record
POST /api/v2/accounting/postings/{postingId}/replays
A Console session with webhook:manage. Never held for a second approver.

Approvals describes how an organisation protects a group of changes.

Where the credentials live

The refresh token and the client secret never enter an accounting table, a log, an audit record or a response. DALP keeps them in the secret manager the deployment selects with secretManager.provider, as Secrets and key handling describes:

  • With local, DALP encrypts each secret under the deployment's master password and stores it in the platform database.
  • With aws, gcp, azure or vault, each secret lives in your AWS Secrets Manager, Google Secret Manager, Azure Key Vault or HashiCorp Vault, under that store's controls and audit.

Every platform service that reads the secret manager can read these secrets. The organisation in a secret's name is a label, not an access control. On local, anyone who holds both database access and the master password can decrypt them. When DALP deletes a secret, Azure Key Vault keeps it recoverable for the vault's retention period, and the version 2 key-value engine of HashiCorp Vault deletes only the latest version. Request audit records redact the callback's state and code values.

Posting records

DALP keeps one posting record for every committed event of a configured organisation, not only for the event types its rules cover, so a rule added later still finds the earlier events. A record carries its status, a reason, the configuration version that decided it, the decided journal, and the Xero journal id and Xero organisation it posted to.

Status
Meaning
pending
Waiting to be decided or posted. This includes a reversal waiting for its original, a retry after a temporary failure and an organisation whose posting is paused.
sealed
Decided under the daily-aggregate policy and waiting for its day's journal.
posted
Xero accepted the journal, or the record's daily journal netted to zero.
failed
Xero refused the journal for good, the payload was pruned before DALP copied the event, or the journal's Xero organisation is no longer connected.
unmapped
No rule covers the event, or the configuration cannot turn it into a Xero journal.
ignored
A test event, an event that is not final, or an event the rules mark as having no accounting effect.

These rules apply to records:

  • A newer configuration version decides every unmapped record again on the next pass, so a corrected rule or account mapping posts what it blocked.
  • A replay returns a failed, unmapped or ignored record to pending. A posted record is never replayed, and its correction is a new journal.
  • A record keeps the event payload until 30 days after it reaches posted or ignored, so a pending, sealed, unmapped or failed record can always be decided again. The decided journal stays for the record's whole life. A replay of an ignored record whose payload is gone is refused.
  • An event whose payload was pruned before DALP copied it is failed with reason payload-pruned, because DALP never decides an event from an empty payload.

Read and retry posting records

GET /api/v2/accounting/postings lists the active organisation's posting records, newest first. It pages with page[limit] and page[offset], 50 records to a page by default, and returns the number of matching records in meta.total. filter[status] takes one status, as in filter[status]=failed, or an eq, ne, inArray or notInArray predicate, as in filter[status][inArray]=failed,unmapped. The list has one order, so a sort other than -createdAt answers 400 with DALP-9355. That is a caller error: drop sort or send -createdAt, and do not retry the same request.

Each record carries its status, a reason code, the last failure message in detail, the journal the rule table built with its narration, debit total and lines, and the Xero journal it reached: xeroJournalId for a record posted on its own, or dailyJournal with the day, sequence, status and Xero journal id of the daily journal it joined. attempts counts the posting attempts, replayCount the replays, and replayable says whether a replay would return the record to pending. The Console's Accounting page shows the same records, with each reason in plain words, and the Xero connection state.

POST /api/v2/accounting/postings/{postingId}/replays returns a failed, unmapped or ignored record that still keeps its payload to pending. It clears the reason, the last failure and any daily journal the record was in. On its next pass the posting job decides an unmapped or ignored record again with the newest configuration version. A failed record whose journal DALP already sent to Xero keeps that journal: when the last send was less than six minutes ago, DALP sends the same journal again as built. Otherwise DALP first looks the journal up in Xero by its narration marker, records the record as posted when Xero holds it, and builds the journal again from the newest configuration version when Xero does not. Any other failed record is decided again with the newest configuration version. The Accounting page's “Retry posting” row action calls this route. The replay has no body, and the platform answers 200 with the record. The response below is illustrative:

POST /api/v2/accounting/postings/4f6b2c1e-8a3d-4e7f-9b25-0c6d1a8e3f72/replays
{
  "data": {
    "id": "4f6b2c1e-8a3d-4e7f-9b25-0c6d1a8e3f72",
    "sourceId": "chain:537001:0x3f9c2a7e5b1d84c06e2a9f4b7d3c1e8a5f6b2d9c0e7a4f1b3c5d8e2a6f9b0c4d:3:0:primary-market.subscription.final@1",
    "eventType": "primary-market.subscription.final",
    "eventVersion": 1,
    "lifecycleState": "final",
    "status": "pending",
    "reason": null,
    "detail": null,
    "configurationVersion": 3,
    "postingPolicy": "per-trade",
    "journal": {
      "narration": "primary-market.subscription.final chain:537001:0x3f9c2a7e5b1d84c06e2a9f4b7d3c1e8a5f6b2d9c0e7a4f1b3c5d8e2a6f9b0c4d:3:0:primary-market.subscription.final@1",
      "occurredAt": "2026-10-12T09:15:00.000Z",
      "amount": "24.99",
      "currency": "USD",
      "lines": [
        {
          "side": "debit",
          "amount": "24.99",
          "currency": "USD",
          "accountKey": "investor-cash",
          "classification": "trades",
          "description": "Subscription"
        },
        {
          "side": "credit",
          "amount": "24.99",
          "currency": "USD",
          "accountKey": "subscription-liability",
          "classification": "trades",
          "description": "Subscription"
        }
      ]
    },
    "xeroJournalId": null,
    "xeroTenantId": null,
    "dailyJournal": null,
    "reversedBy": null,
    "attempts": 2,
    "replayCount": 1,
    "replayable": false,
    "createdAt": "2026-10-12T09:16:04.000Z",
    "updatedAt": "2026-11-10T14:02:31.000Z"
  },
  "links": { "self": "/api/v2/accounting/postings" }
}

A record that is already pending cannot be replayed again, so a second replay while the record is still pending answers 409 with in-progress. The platform writes an audit record for each replay. A refused replay names its cause:

Status
Error
data.refusal
What the platform observed
What to do
404
DALP-20545
None
The active organisation has no posting record with this id.
List the records and use an id from the list.
409
DALP-20546
posted
Xero holds the journal, or the record's daily journal netted to zero.
Correct the journal with a new journal in Xero.
409
DALP-20546
in-progress
The record is pending or sealed, so the posting job decides it on its next pass anyway.
Read the record again after the next pass.
409
DALP-20546
payload-pruned
The record is failed, unmapped or ignored, but its payload is gone.
Book the event by hand, because DALP cannot decide it again.

Neither error is retryable, and neither means the record changed: a refused replay leaves the record as it was.

Posting policies

The configuration's postingPolicy is per-trade, the default, or daily-aggregate. DALP stamps the policy on each record when it decides the record, so a policy change affects only records decided after it.

Under per-trade, each record posts as its own journal, dated the UTC day of the event's timestamp.

Under daily-aggregate, a decided record waits as sealed for its UTC day. The day closes at 02:00 UTC the next day. DALP then builds one balanced journal dated that day from the day's sealed records and posts it:

  • DALP sums the lines per account, side, tax type and tracking category, and a debit and a credit on one account net to one line.
  • A journal that nets to zero is recorded as posted, and nothing is sent to Xero.
  • A day whose total would reach ten billion in the ledger currency splits into more than one journal.
  • A record decided after its day's journal was built goes into a supplementary journal for that day, with the same date. When Xero refuses it because the period is locked, the journal is failed with a case.
  • A record that cannot be mapped when the day closes is unmapped with a case, and the day's other records still post.
  • When Xero refuses a daily journal, the journal and its records are failed with a case. A replayed record joins the next journal for its day.

Reversals

A retraction such as a trade bust posts as the reversal of the journal its original posted. The reversal mirrors the original's lines on the same Xero account codes, even when the account mapping changed after the original posted.

  • Under per-trade, a reversal waits without a case until its original has posted.
  • Under daily-aggregate, a reversal of an original in the same day's open journal joins that journal, so the two net out. A reversal on a later day waits until the original's daily journal has posted.
  • DALP reverses an original at most once.
  • A reversal still waiting after one day of connected time raises an Accounting event blocked case. It keeps retrying, at most an hour apart, and posts once its original posts.

Operations cases

DALP raises an operations case whenever an event cannot post, and lists it with the organisation's other cases. Work an operations case describes how to acknowledge and resolve one.

Case
Kind
Raised when
Accounting event blocked
accounting-event-blocked
An event has no rule, names an asset outside the rule table, is malformed, or carries an amount the ledger cannot take. Also when the configuration cannot turn a journal into a Xero journal, such as an account key with no Xero account code, a tax-bearing line or a journal outside the Xero base currency, and when a reversal has waited more than one day. One case per event type and cause while it is open. A configuration version that fixes the cause posts the blocked records.
Accounting posting failed
accounting-posting-failed
Xero refused a journal for good, a journal could not be built, payloads were pruned before DALP copied the events, or a committed event waited more than an hour before DALP could read it, which points at a stalled event relay. One case per failure class while it is open.
Xero connection needs attention
accounting-xero-connection
One case per organisation. Its reason starts with needs-reconnect when Xero refused the grant, authentication when Xero refused the posting credentials with a persistent 401 or 403 on a journal post, tenant-not-connected when the configured Xero organisation is not among the grant's organisations or a journal's Xero organisation was disconnected, rate-limited when Xero asked DALP to wait more than ten minutes, revocation-pending when Xero has not confirmed a revocation, or client-unavailable when DALP could not build a Xero client, for example because the stored client secret is missing.

Accounting event blocked is a warning. The other two are critical. DALP resolves a rate-limited case itself once Xero accepts a journal again.

Xero limits

Xero limits each app by its tier. A Starter tier app allows 1,000 API calls a day and 5 connected organisations, which fits a trial and not a production book. DALP spends about two calls on a post once the access token and the tenant check are cached, so a production organisation needs a higher Xero tier, or the daily-aggregate policy. When Xero answers 429, DALP pauses that organisation until Xero's retry time, 60 seconds when Xero names none, and the other organisations keep posting.

A Xero Demo Company resets every 28 days and gets a new tenant id. Posting then pauses with a Xero connection needs attention case until an administrator connects again and stores a configuration version with the new tenant id.

What DALP does not post

  • Mirror-token issuance and burn. These events reach subscribed webhook endpoints only, not the organisation's committed events, so the posting job never sees them.
  • A completed partial redemption or buyback. No event marks when its cash moves, so such an event ends unmapped with an Accounting event blocked case.
  • Events from before the organisation's first configuration version, and events whose payload was pruned before DALP copied them.
  • Tax-bearing journal lines. A rule that produces one ends unmapped.

Book these by hand, or through a custom subscriber.

Run your own subscriber

A custom subscriber posts what DALP does not: journals for a ledger other than Xero, and the events in What DALP does not post that reach webhook endpoints. It also reconciles the ledger's totals against DALP's read APIs on a schedule. When a subscriber and DALP both post to the same Xero organisation, keep their rules apart, or a movement posts twice.

The rest of this page is the contract for such a subscriber: what is on the feed, how to verify a delivery, the rule table, and the idempotency and replay rules. DALP's own posting job follows the same rule table.

A subscriber turns each signed delivery into at most one ledger journal.DALP sends a signed webhook delivery with evt_id, source_id and lifecycle_state. The subscriber verifies the signature and skips test events. It then branches on lifecycle_state. A pending, provisional or recalled event never posts. A final event goes through its posting rule into a balanced debit and credit journal. A retracted event is reversed by its reversal rule when one exists, or by looking up the event its supersedes field names. The reversal mirrors the original journal. A retraction of a pending or provisional event posts nothing. Both kinds of journal post once, keyed on source_id, so a replay builds the same entry.A subscriber turns each signed delivery into at most one ledger journal.DALP sends a signed webhook delivery with evt_id, source_id and lifecycle_state. The subscriber verifies the signature and skips test events. It then branches on lifecycle_state. A pending, provisional or recalled event never posts. A final event goes through its posting rule into a balanced debit and credit journal. A retracted event is reversed by its reversal rule when one exists, or by looking up the event its supersedes field names. The reversal mirrors the original journal. A retraction of a pending or provisional event posts nothing. Both kinds of journal post once, keyed on source_id, so a replay builds the same entry.

A signed delivery is verified, branched on lifecycle_state and posted once per source_id, so a replay builds the same entry.

What is on the feed

An endpoint receives the events it subscribes to. Each delivery is one JSON envelope with these fields the ledger side reads:

Field
Meaning for a subscriber
evt_id
Identifies this recorded event. Retries carry the same evt_id, and a replay of the same occurrence carries a new one.
source_id
Identifies the underlying occurrence. It stays the same across replays. Every event except a test event carries one.
type, version
The event name and its schema version. Match rules on both.
lifecycle_state
One of pending, provisional, final, retracted, recalled.
payload
The typed event data, including amounts and timestamps.
supersedes
On a retraction, the evt_id of the event it replaces.
is_replay
true on an envelope created by a replay request.
is_test
true on a test event. Never post it.

The event catalogue and market and settlement events list the events the platform sends, their triggers, and the receiver's action. Payload schemas and examples are in the machine-readable event definitions and TypeScript SDK. The manifest groups the events into these families:

Family
What it reports
token
Transfers, corporate actions and their retractions, fees, period closes and bond payouts
asset
Asset creation, issuance, burns, pause and unpause
primary-market
Token sale subscriptions, deliveries, refunds and proceeds withdrawals
trading-venue
Orders, trades, trade settlement and their busts
xvp
Cross-party settlement lifecycle
compliance
Freezes, unfreezes and verification verdicts
identity
Registration, claims and status
access-control
Role grants and revocations
custody
Account creation, signer changes and custody gateway settlements
funding
Funding requirements for operations
operations
Operations cases opened, acknowledged and resolved
reporting
Wallet confirmations, client statements and filing deadlines
transaction
Transaction request outcomes, from broadcast to confirmation, failure or cancellation
bank-rail
Credit and cash-out milestones, bank reversals and returned payouts

Bank rail events

Subscribe explicitly to bank-rail.credit.* or bank-rail.cash-out.*, because the default state patterns skip their milestone names. These events carry amount, currency and occurredAt, so they are journal candidates when your books have a rule for that milestone. An admitted or requested event records work in progress. Its envelope state final means the rail committed that observation, not that the payment completed.

Bank amounts are decimal currency values: "12.50" EUR is 12.50 EUR, rather than token base units. Convert them exactly to your ledger's minor units before applying the integer-amount rule below. Never feed a fractional bank amount to a rule that expects token base units.

Choose the posting milestone for each business movement. Posting the credit's admitted and minted events, or posting both its minted event and the token issuance or transfer, can count the same movement twice. A released cash-out can later report paid if the bank settles it late and the rail recovers and burns the tokens. Partial recovery sends no event. A cash-out's paid event can be followed by returned, which means the rail admitted the re-credit, not that it minted. The re-credit's admitted event identifies that cash-out through provenance.cashOutId. Wait for its minted event to book the completed mint.

A recall's recovered and written-down amounts, and each recorded claw, appear on the recall sheet on the Console's Payments tab. bank-rail.credit.recalled arrives only when completed recalls cover the whole credit. Its amount is the completing bank reversal's amount, and earlier partial recalls send no credit status event. Read the recall records before constructing a journal for the whole reversal history. Events expose neither recovered amounts nor write-down figures, so they cannot supply those postings on their own. See the bank rail catalogue for each trigger.

Fee events

Fees arrive as their own events. For each token, post its fees either from the fee events or from the transfers that move them, never from both.

Event
What it reports
Fee id
trading-venue.trade-fee.final
The venue fee charged to one side of a settled trade, buyer or seller.
<tradeId>:<side>
trading-venue.trade-fee.retracted
A trade bust reversed that fee.
<tradeId>:<side>
token.fee.final
One token fee charge: transaction, external, AUM, accrual or fund management fee.
<blockNumber>:<logIndex>:<feeType>
token.fee-period-close.final
One accrual period close.
periodCloseId, <blockNumber>:<logIndex>

Custody gateway settlements are on the feed too: custody.gateway-deposit.settled and custody.gateway-withdrawal.settled. The custody event catalogue describes their triggers.

A token fee event is final only. DALP sends it once the chain finality watermark reaches its block, so the platform never retracts it. The basis field is collected or accrued, and a rule can branch on it with when. The payer field is withheld from thin payloads. The fees-and-charges report row for a token fee has the subject reference <feeId>:<payer>, with an empty payer part when the fee has no payer. A fee id is unique within one chain only, so a ledger that takes events from more than one chain keys token fees on source_id.

A transaction, AUM or management fee also moves tokens on chain, so token.transfer.final or asset.issuance.final fires for the same value. An external fee paid in a DALP token fires token.transfer.final too. A rule table cannot exclude one transfer leg, so a ledger that maps token.transfer.final for a token also books its fees through that event. For each token, map either its fee events or its transfers, not both.

token.fee-period-close.final reports the on-chain sum of every accrual since the previous close in amount. No tokens or cash move at a close. A ledger that posts accruals to a receivable moves that total on at the close. An accrual refused for not being a whole number of cents leaves the receivable short against the close until someone posts it by hand.

Most token fees are denominated in the token itself. Listing a security token in a rule table's assets values it at par, one token per ledger currency unit. Leave such fees unmapped until a valuation exists. Your subscriber never rounds, so it refuses a fee amount that is not a whole number of cents. Basis-point fees on 18-decimal tokens are usually not whole cents.

An endpoint receives these events from its deployment onward. The next indexer rebuild also sends them for every past fee log at or after the endpoint's start block. Deduplication on source_id posts each fee once. A ledger that already booked those fees from the period reports should exclude the earlier period.

Bond payout events

A bond pays its coupons and its maturity redemption one holder at a time, when the holder claims or redeems, so the cash reaches the feed as one event per claim or redemption.

Event
What it reports
Domain key
token.coupon-claim.final
One holder's coupon claim on a fixed treasury yield feature or a legacy fixed yield schedule.
claimId, <blockNumber>:<logIndex>
token.maturity-redemption.final
One holder's redemption of matured bond tokens on a maturity redemption feature or a legacy bond.
redemptionId, <blockNumber>:<logIndex>

Both events are final only. DALP sends each one once the block of its claim or redemption reaches chain finality, so the platform never retracts it, and a replay of the same log sends no second event. amount is the cash paid to the holder, in base units of denominationAddress. A coupon claim's amount can be less than the sum of the periods from fromPeriod to toPeriod, so post amount and not a figure computed from the coupon rate. A redemption also carries tokenAmount, the redeemed tokens. featureAddress is the contract that paid, which is the bond itself for a legacy bond. The holder field is withheld from thin payloads. Converting accrued yield into tokens pays no cash and sends no event.

A bond coupon corporate action completes when it is created, and a maturity redemption completes when no tokens are left, so token.corporate-action.completed for either one moves no cash. Post the cash from the bond payout events and treat those completions as booked elsewhere.

When the denomination asset is a DALP token, the payment also fires token.transfer.final. For each bond, map either its bond payout events or the denomination transfers that pay its holders, not both.

An endpoint receives these events for claims and redemptions that reach finality after the platform started sending them. Earlier claims and redemptions never get one, and an indexer rebuild does not send them.

If your organisation stores its configuration from the preset described in Store a posting configuration, the preset posts both events in each quote asset of the ledger currency, with classification servicing. A coupon claim debits servicing.distributions.<suffix> and a redemption debits servicing.redemptions.<suffix>, and both credit client-money.liability.<suffix>. An event in another denomination matches no rule, so the subscriber refuses it. The preset ignores token.corporate-action.completed for bond coupons and maturity redemptions.

Not every event can become a journal. A journal needs an amount in the payload and an ISO 8601 timestamp for when it happened. For example, token.transfer.final carries amount, tokenAddress and occurredAt, the time of the block that holds the transfer, so a rule can post it. A transfer from before occurredAt existed has no timestamp field, so your subscriber refuses it as malformed. primary-market.subscription.final carries paymentAmount, paymentCurrencyAddress and occurredAt too. Check the event's schema in the machine-readable event definitions before you write a rule for it.

Verify and accept a delivery

Treat the signature as the gate before any posting logic runs. Verify every delivery as Verify the signature describes, with the SDK's verifyWebhook or any Standard Webhooks library. The signed content is the exact raw body, so verify before you parse it. While a secret rotates, webhook-signature can hold two signatures. Either one is enough.

Answer with a 2xx as soon as you have stored the event durably, then post to the ledger afterwards. A reply slower than 20 seconds counts as a failure and triggers a retry of work you may already have done. The response and retry table and counter-signed receipt contract define the delivery response. Receive DALP webhooks covers secret rotation.

A signature proves DALP sent the message and that nobody altered it. It does not tell you the event is final. Read lifecycle_state next.

From event to journal

A rule table maps the organisation's events to journals. DALP's posting job reads the one in the organisation's stored configuration, and a custom subscriber keeps its own. Two kinds of rule exist.

A posting rule names an event type and version, an optional exact-match condition on payload fields, a classification, a debit account key and a credit account key. It also names the payload fields that hold the amount, the asset, the timestamp, an optional stable domain key, and any reference fields to copy into the description. The classification is one of trades, fees, servicing or corrections.

A reversal rule names the event type it undoes and the payload field that identifies the original.

An account key is a stable, organisation-chosen name such as investor-cash. The organisation's account mapping resolves each key to a real ledger account, per classification, in Xero or elsewhere. Rules never carry ledger account codes, so a chart-of-accounts change touches the mapping and not the rules.

A rule table is valid only when all of these hold. Otherwise reject the whole table instead of fixing it up silently.

  • Every account key resolves in the account mapping for the rule's classification.
  • No two rules can match the same event, and a type does not have both posting and reversal rules.
  • Every reversal rule targets a type that has a posting rule, and both key on the same field.
  • Every asset's currency is the ledger currency.
  • No posting rule targets a pending or provisional event.

Which events post

Lifecycle state
What the subscriber does
Test event (is_test)
Skip.
pending, provisional
Skip. These can still be retracted, so they never post.
final
Apply the matching posting rule.
retracted
Apply the matching reversal rule, if the organisation wrote one. A retraction of a pending or provisional event needs no rule: nothing was posted, so acknowledge it and do nothing.
recalled
Reserved. The platform sends no new event in this state. A Replay can resend one that DALP 3.1 stored: acknowledge it and post nothing.

Your subscriber refuses a final event with no matching rule, or a retracted event that reverses a posted entry but has no rule, with a named error rather than guessing. Treat that refusal as a signal to extend the rule table, and keep the delivery for later. Acknowledge test events, recalled events, and every pending or provisional event as handled no-ops, including the retraction of a pending or provisional event, since nothing was posted for it. Refusing those would only cause retries.

The journal shape

A posting produces one balanced entry with two lines.

Entry field
Value
sourceEntryId
The envelope's source_id.
occurredAt
The rule's timestamp field.
narration
<type> <source_id>.
Debit line
The debit account key, the amount, and sourceLineId of <source_id>:debit.
Credit line
The credit account key, the same amount, and <source_id>:credit.

Each line carries the classification, the ledger currency and a description made of the rule's text plus field=value pairs for the reference fields. Because both lines carry the same amount, the entry always balances.

Amounts

Payload amounts for token and asset events are integer strings in the asset's smallest unit. The rule's asset field names an entry in the organisation's asset table, which gives the ledger currency and the asset's decimals. The subscriber converts exactly to cents with integer arithmetic. It never rounds. It refuses any amount that is zero, is not a whole number of cents, or is ten billion or more in the ledger currency. A refused amount needs a human decision: a sub-cent amount in the ledger currency is a rule or asset-table problem, not something to round away.

Worked example: one subscription

An investor subscribes to a token sale. DALP sends this event once the chain reaches finality for its block. Fields the rule does not use are omitted:

{
  "evt_id": "evt_7c12e838e3f9a8bafcaa292f86ed41e7",
  "source_id": "chain:537001:0x3f9c2a7e5b1d84c06e2a9f4b7d3c1e8a5f6b2d9c0e7a4f1b3c5d8e2a6f9b0c4d:3:0:primary-market.subscription.final@1",
  "type": "primary-market.subscription.final",
  "version": 1,
  "lifecycle_state": "final",
  "payload": {
    "saleAddress": "0x7777777777777777777777777777777777777777",
    "paymentCurrencyAddress": "0x8888888888888888888888888888888888888888",
    "paymentAmount": "1000000000",
    "occurredAt": "2026-10-12T09:15:00.000Z"
  }
}

In the tables of this example, <source_id> stands for that source_id value. The organisation's rule table holds one posting rule for primary-market.subscription.final at version 1. It uses classification trades, debit key investor-cash, credit key subscription-liability, amount field paymentAmount, asset field paymentCurrencyAddress, timestamp field occurredAt and reference field saleAddress. Its asset table maps 0x8888…8888 to currency USD with 6 decimals.

The subscriber computes the amount: 1,000,000,000 base units at 6 decimals is 1000.00 USD, exactly 100,000 cents. It then builds this entry:

Field
Value
sourceEntryId
<source_id>
occurredAt
2026-10-12T09:15:00.000Z
narration
primary-market.subscription.final <source_id>
Debit
investor-cash, 1000.00 USD, line id <source_id>:debit
Credit
subscription-liability, 1000.00 USD, line id <source_id>:credit

DALP's posting job, or your own ledger connector, then resolves investor-cash and subscription-liability through the trades account mapping and posts the entry. In Xero, that is one manual journal.

Store a posting configuration

An organisation keeps its rule table, chart-of-accounts mapping and posting policy in DALP with PUT /api/v2/accounting/configuration, and reads the newest version with GET. Who can call what lists the permission and approval each needs. The body names three things:

  • postingPolicy: per-trade, the default, or daily-aggregate, as Posting policies describes.
  • mapping: the Xero organisation's xeroTenantId, its base currency as defaultCurrency, the Xero account code for each account key per classification under accounts, and any taxTypes and trackingCategories.
  • rules: the rule table that From event to journal describes.

Each write creates a new immutable version. Send the version you read as expectedVersion, or null for the first one. A stale version answers 409, and repeating a write whose content matches the current version returns that version. A rule table that cannot post against its account mapping answers 422, with each problem in data.issues.

A write sends its rules either as a complete rule table or as preset parameters. The parameters list the organisation's quote assets, mirror assets, other assets and bank rail currencies, and give each quote asset, mirror asset and currency an account suffix. DALP expands them into a rule table that names each asset's accounts with its suffix, such as client-money.liability.<suffix>, and stores the expanded table in the version. A version keeps that table, so a change to the preset reaches an organisation only when it writes its configuration again. A version written before the preset covered bond payouts has no rule for token.coupon-claim.final or token.maturity-redemption.final, so those events are refused until the organisation writes its configuration again. That write needs servicing.redemptions.<suffix> in the servicing account mapping for every quote asset in the ledger currency, or it answers 422.

The first version starts the posting record intake: DALP copies the organisation's events from that version's creation onwards and posts them once Xero is connected. A record that may already have reached Xero keeps the version that decided it.

Reversals and idempotency

Two keys keep the ledger correct.

webhook-id deduplicates deliveries. The header carries the envelope’s evt_id. A retry repeats the same webhook-id, so record the ones you have handled and answer 2xx for any you recognise.

source_id makes the posting idempotent. A replay creates a new evt_id for the same occurrence, so evt_id alone would post it twice. Because the journal's sourceEntryId is the source_id, a replay produces the identical entry, and a ledger connector that keys on the organisation plus sourceEntryId posts it once.

Treat a posted entry as immutable. Never edit it to correct it. A correction is a new entry with a new source id.

The reversal key

Each posting also has a reversal key. It is <type>#<value> where the value is the payload field the rule names as its domain key, for example a trade id. When a posting rule declares no domain key, the key is <type>#source:<source_id>. Store the reversal key with the entry you post, because a reversal looks the original up by it.

A reversal event, such as a trade bust, names the original through a payload field that holds the same value as the original's domain key. A rule is only valid when both events use the same field. Many retractions carry only supersedes, the evt_id of the original, and no such field. A reversal rule cannot express those, because it must name a payload field. Handle them in the subscriber outside the rule table: look up the event named by supersedes in your stored deliveries and use its stored reversal key. The subscriber then:

  1. Builds the key <reversed type>#<value read from the retraction's payload>, or takes the stored key of the event named by supersedes.
  2. Looks up every entry posted under that key for this organisation.
  3. Posts one new entry whose lines mirror the originals: debit becomes credit and credit becomes debit, same account keys, same amounts, line ids <original line id>:reversal, and descriptions starting “Reversal of”.
  4. Uses the retraction's own source_id as sourceEntryId, with narration <type> reverses <reversal key>.

If the lookup finds nothing, decide by whether the original can still arrive. When the event named by supersedes is one your endpoint has stored but has not posted, or a delivery may still be in flight, keep the reversal queued and retry it yourself after you acknowledge the delivery, because deliveries can arrive out of order and DALP does not resend one you acknowledged. Once the original turns out to be a pending or provisional event, which never posts, mark the retraction handled and stop retrying. When no such event exists for your endpoint, there is nothing to reverse: keep the retraction, log the missing original, acknowledge with a 2xx and post nothing. Never post a reversal for another organisation's entry.

Delivery, retries, ordering and replay

DALP delivers at least once, never exactly once. The retry contract defines response classes, backoff, and the three-day retry window.

Ordering is per endpoint. A later eligible event waits while an earlier event is pending, including during backoff. A linked correction waits until its cause was delivered or can no longer be delivered. Finality waits do not block later events, and Replay can send an older observation after a newer one. Keep the ledger safe when a retraction names an event you never received, or when posting runs after acknowledgement. The ordering contract defines those limits.

Gaps are possible. While an endpoint is switched off, whether an administrator switched it off or its circuit breaker opened after 30 failed attempts in a row, the platform records no events for it, and neither Resend nor Replay recovers them. Reconcile your ledger totals against DALP's reads on a schedule instead of assuming the feed is complete.

Replay recovers recorded events. The Resend and replay reference defines the recovery calls. Replay selects events the platform recorded for an endpoint, by a block range or one evtId. A replayed envelope has a new evt_id, is_replay: true and the original source_id, so replaying is safe for a subscriber that keys on source_id. Use it after a subscriber outage longer than the three-day retry window, or for a delivery that ended in a terminal failure. The retention cleanup deletes a settled event 30 days after its delivery ended, so replay returns nothing for an event older than that. After a longer outage, rebuild the missing entries from DALP's read APIs instead. Replay cannot help when DALP never recorded a notification for your endpoint, as for the period an endpoint was switched off. If a replay of the affected range returns nothing for an event your reconciliation shows is missing, the ledger stays incomplete until you obtain the data from DALP's read APIs or your DALP contact.

Frequently asked questions

Why not post provisional events and correct them later?

A provisional event can be retracted by a chain reorganisation. Posting it means posting a reversal later and showing both in the ledger. A final chain event is sent only after its block reaches the network's finality depth, so waiting for it avoids the reversal. trading-venue.trade-settled.final and trading-venue.trade-fee.final are the exception: the platform sends them when the settlement transaction confirms, before that depth, so reconcile trades against the chain before you close a period. A final event can still be undone by a business reversal such as a trade bust, which is what the reversal rules cover.

What if your rules need a field the thin payload removes?

An endpoint's default thin payload shape strips personal data fields. Amounts and timestamps stay. If a rule reads a field that thin strips, the machine-readable event definitions identify it. Follow the payload shape contract to acknowledge the field and change to the full shape, or choose a different field.

Next steps

Was this page helpful?