How an agency integrates with Actum, machine to machine.
There are two halves. Inbound, your software calls the same REST routes the console calls, authenticated with a scoped API key instead of a person's session. Outbound, Actum posts signed webhooks to an endpoint you register. Both ride one event spine, so what you receive is the same fact the console shows an operator.
Credentials are issued to vetted agencies from the console's Integrations tab. If your agency is not on Actum yet, start at the agency directory.
Call the regular routes with Authorization: Bearer ak_live_…. A key is pinned to one agency, checked against a scope per route (bills:write, rent:collect, rentals:write, webhooks:manage, and so on), and shown to you exactly once at creation. Only a hash is stored, so a lost key is replaced, never recovered.
Machine traffic is rate-limited per key rather than per IP, so one integration behind a shared egress address cannot starve another. Audit rows attribute every action to api-key:<id> — which is why a key belongs to the agency and never to one person's account. Keys survive staff turnover; people do not.
POST /api/agency/webhooks
Authorization: Bearer ak_live_…
{
"agencyId": "ag_…",
"url": "https://example-agency.com/actum/webhook",
"topics": ["bill.issued", "rent.collected"]
}The URL must be public HTTPS. Localhost, private ranges, and hosts resolving to reserved addresses are refused at registration, and the same check runs again before every delivery. Deliveries never follow redirects — a 3xx response counts as a failed attempt. The registration response carries the endpoint plus a signing secret (whsec_…), shown once. Only topics that actually fire can be subscribed to; a declared but not-yet-live topic is refused with a 400.
GET /api/agency/webhooks?agencyId=… lists your endpoints with their health (lastSuccessAt, lastError). DELETE /api/agency/webhooks/{id} disables one; its delivery history survives.
Each event is one POST to your URL. The JSON body is an envelope — the per-topic fields documented below arrive nested under data, not at the root:
{
"id": "evt_…", // stable per business event — dedupe on this
"topic": "bill.paid",
"data": { … } // the per-topic fields listed below
}Four headers ride along.
| Actum-Topic | The event topic, for example bill.paid. |
| Actum-Event-Id | Stable per business event (evt_…). Two endpoints subscribed to the same event share it — dedupe on this. |
| Actum-Delivery-Id | Unique per delivery row (whd_…) — one per endpoint per event, constant across that delivery’s retry attempts. |
| Actum-Signature | t=<unix-seconds>,v1=<hex> — see verification below. |
Respond with any 2xx within 10 seconds. Anything else, including a timeout or a redirect, counts as a failed attempt.
v1 is HMAC_SHA256(signingSecret, "<t>.<rawBody>"), hex-encoded — the same scheme Stripe uses, and the same one Actum uses to verify Stripe inbound. t is unix seconds. Verify against the raw request body, before any JSON parsing.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyActumSignature(header, rawBody, secret, toleranceSec = 5 * 60) {
const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header ?? '');
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return v1.length === expected.length &&
timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));
}Deliveries are queued durably and attempted by a runner every five minutes. A failed attempt is retried on a backoff of 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, then daily — eight attempts in total. After the last failure the delivery is dead-lettered, visible to Actum operations, who can requeue it; the endpoint's lastError is updated.
Delivery is at-least-once. After a timeout your handler may have processed a body whose 2xx never reached us, so idempotency on Actum-Event-Id is required, not optional.
If POST /api/rent-collections answers {"ok": false, "parked": true}, the collection settled at the custody provider but Actum's posting failed afterwards. Retry it by re-posting the same sourceRef: the custody idempotency key rent:<sourceRef> dedupes the collection, so the money is never taken twice and the posting completes on the retry.
A fresh sourceRef is a new collection. Once a live payment rail is connected, it executes a second real direct debit against the tenant.
The field lists below are the contents of the envelope's data object. Every topic carries agencyId plus the ids an external system needs, never internal snapshots. Fields ending in Minor are integer minor units encoded as strings; fields ending in Usd are whole USD numbers.
| bill.issued | A bill needs owner funding | billId, propertyId, category, amountMinor, currency, counterparty, dueDate, funding |
| bill.paid | A bill settled, by any method | billId, propertyId, category, amountMinor, currency, counterparty, method, paidAt |
| bill.cancelled | A bill was withdrawn | billId, propertyId, category, amountMinor, currency, counterparty, wasAccrued |
| rent.collected | A rent receipt ran the waterfall | propertyId, grossMinor, currency, managementFeeMinor, billsPaid, reserveContributionMinor, claimableMinor, sourceRef |
| rent.missed | A rent period closed with no receipt | propertyId, periodKey, expectedMinor, currency |
| shortfall.opened | Must-pay arrears exceed the property's funds | propertyId, triggerBillId, requestedBillIds, currency |
| shortfall.cure_opened | The mandate's cure right activates | propertyId, cureDeadline, curePeriodDays |
| shortfall.forced_settlement | The deed is force-listed | propertyId, reason, askingPriceUsd |
| shortfall.resolved | Arrears are covered again | propertyId, billsPaid |
| verification.requested | A buyer pays for a verification | requestId, propertyId |
| verification.scheduled | The inspection is scheduled | requestId, propertyId, scheduledDate |
| verification.report_published | The report goes public on the listing | requestId, propertyId |
| verification.needs_clarification | The agency needs input from the owner | requestId, propertyId |
| verification.failed | The verification cannot be completed | requestId, propertyId |
| rental.proposal_created | An agency proposes rental terms | proposalId, propertyId, monthlyRentUsd |
| rental.proposal_accepted | The owner accepts the terms | proposalId, propertyId |
| rental.proposal_declined | The owner declines the terms | proposalId, propertyId |
| rental.management_stage_changed | The lease lifecycle advances. `active` means the lease commenced | proposalId, propertyId, managementStage |
| rental.terminated | The managed rental ends | proposalId, propertyId |
| digitization.requested | An owner asks the agency to digitalize a property | requestId, contactName, contactEmail, contactPhone, locationQuery, currentlyRented, submittedAt |
| digitization.stage_changed | The pipeline advances a stage | requestId, stage |
| digitization.listing_ready | The bearer deed is minted and the listing is live | requestId, propertyId |
| listing.created | A deed is listed for sale | propertyId, askingPriceUsd, forced |
| listing.delisted | The seller withdraws the listing | propertyId |
| listing.sold | A sale settles, instant or by accepted bid | propertyId, priceUsd, method |
| offer.received | A bid lands on a property you manage | bidId, propertyId, amountUsd |
| property.metadata_updated | Property fields change | propertyId, fields (names only, never values) |
Integration questions go through your agency console. If you are already on Actum, open a request from the console and it reaches the same queue as everything else.