Skip to content

Webhooks

Webhooks in DayZero go both ways:

  • Outbound webhooks let you register an HTTPS URL and receive signed events when Order-to-Cash records change (orders countersigned, invoices sent, payments received), when month-end close inputs arrive, and, for partner credit programs, for every credit.* event. This is the part you integrate with.
  • Inbound webhooks are how Stripe, Shopify, Plaid, Square and Ramp notify DayZero. They are set up automatically when you connect an integration; nothing to configure.

Outbound Webhooks

Register an endpoint

bash
curl -X POST "https://api.ondayzero.com/api/v1/webhooks/o2c" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/dayzero",
    "event_types": ["order.countersigned", "invoice.sent", "payment.received"]
  }'

The response includes the plaintext secret exactly once — store it; later reads never return it (rotate it instead, see Test and manage). Supply your own secret (32–255 characters) if you prefer. url must be HTTPS and resolve to a public address (private, internal and cloud-metadata addresses are refused with 400). event_types is required, and a type that is neither listed below nor a credit.* event is refused with 400 (CREDIT_101) naming it. is_active: false registers an endpoint without delivering to it yet.

Event types

Event Emitted when Payload fields (besides event)
order.countersigned An Order-to-Cash order is countersigned order_id, contract_id, customer_id, order_value_cents
contract.activated A contract becomes active contract_id, status
invoice.drafted A contract invoice is drafted for a billing period contract_id, invoice_id, amount_cents, period_key
invoice.sent A contract invoice is sent to the customer contract_id, invoice_id, amount_cents
payment.received A payment is applied to a contract invoice invoice_id, contract_id, amount_cents, method, total_paid_cents, status
drawdown.approved Hours are approved against a contract contract_id, drawdown_id, hours
contract.completed A contract reaches its end contract_id, status

Every payload is a flat JSON object whose event field names the type. Amounts are in cents.

Partner credit events (credit.loan.originated, credit.payment.recorded, and the rest of the credit.* family) can be subscribed to on the same endpoints. They use their own envelope (id, sequence_no, type, occurred_at, credit_line_id, program_id, data) and are documented in Partner Credit Lines.

Month-end close events

These five tell a close agent that data is in, so it starts then instead of on a clock. They are not credit events: a credit-scoped API token cannot subscribe to them.

Event Emitted when Payload fields (besides event)
accounting_period.created An accounting period is created, by a person or automatically. Sent once per period period_id, period_start, period_end, status, created_by (user or auto)
plaid.sync.completed A Plaid sync of a bank connection finishes. One event per ledger the connection feeds ledger_id, ledger_name, plaid_item_id, added, modified, removed, pending, posted, window_start, window_end, completed_at
stripe.payout.landed A Stripe payout is tied to its bank deposit. Sent once per payout stripe_payout_id, transaction_id, ledger_id, amount_cents, gross_cents, fee_cents, invoice_ids
reconciliation.completed A reconciliation is completed and locked reconciliation_id, ledger_id, start_date, end_date, balanced, difference_cents, matched, unmatched_statement, unmatched_ledger
anomaly.created A scan stores a finding it has not stored before. One event per new fingerprint, at most 100 per scan fingerprint, anomaly_type, severity, title, amount_cents, date, entity_ids, and truncated plus total_new on the last event when the scan found more
json
{
  "event": "plaid.sync.completed",
  "ledger_id": "0198a2c4-6e0b-7d3a-9a51-3f6f0c1d2e77",
  "ledger_name": "Chase Checking 1234",
  "plaid_item_id": "0198a2c4-1111-7d3a-9a51-3f6f0c1d2e11",
  "added": 42,
  "modified": 3,
  "removed": 1,
  "pending": 5,
  "posted": 40,
  "window_start": "2026-10-01T09:00:00+00:00",
  "window_end": "2026-10-01T09:00:12+00:00",
  "completed_at": "2026-10-01T09:00:12+00:00"
}

For a period or a payout the delivery id (X-DayZero-Delivery-Id) is period:{period_id}:created or payout:{stripe_payout_id}:landed, the same however many times the event is noticed, and DayZero records it once. An anomaly's delivery id is its fingerprint.

Close events

These nine keep a close dashboard up to date while a month is being closed. Like the events above they are not credit events. Each payload also carries business_id and emitted_at (ISO timestamp, UTC), so one receiver can serve many businesses. Dates are ISO YYYY-MM-DD; period_start is the first day of the month being closed.

Event Emitted when Payload fields (besides event, business_id, emitted_at)
close.window_opened The close of a month starts (a daily check finds today is the business's close-start business day). Sent once per period period_start, period_end, business_day (N), date
close.progress A close step changes status period_start, total, done, blocked, overdue, failed, business_day (today's business day of the month, or null on a weekend or holiday), updated_job_id
close_step.overdue A daily check finds a step past its due business day. Sent once per step per day period_start, job_id, title, owner, due_business_day, status, date
pending_change.proposed An agent proposes a change outside a round; it waits for someone with write access. Sent once per change pending_change_id, tool_name, summary, tier, impact_cents (or null), proposed_via, external_ref (or null), review_url, expires_at
pending_change.decided A proposed change is applied, rejected, fails or expires. Sent once per change pending_change_id, tool_name, status (applied, rejected, failed or expired), round_id (or null), decided_by (email or null), note, summary, error (or null), external_ref (or null)
approval_round.submitted A round of proposed changes is submitted for a decision. Sent once per round round_id, title, period_start (or null), pending, external_ref (or null), review_url, expires_at
approval_round.decided A round of proposed changes is reviewed. Sent once per round round_id, status, approved, rejected, left_pending, decided_by, external_ref (or null)
document_request.fulfilled The client sends a document that was asked for. Sent once per request document_request_id, title, fulfilled_at
transaction_clarification.answered The client answers a question about a transaction. Sent once per question clarification_id, transaction_id, answer (first 500 characters), answered_at

Business days follow the Federal Reserve bank-holiday calendar: Monday to Friday, less New Year's Day, Martin Luther King Jr. Day, Washington's Birthday, Memorial Day, Juneteenth, Independence Day, Labor Day, Columbus Day, Veterans Day, Thanksgiving and Christmas. A holiday on a Sunday is observed the Monday after; one on a Saturday is not observed.

The events sent once have a stable delivery id: close-window:{period_start}:opened, close-step:{period_start}:{job_id}:{date}:overdue, pending-change:{pending_change_id}:decided, approval-round:{round_id}:decided, document-request:{document_request_id}:fulfilled and clarification:{clarification_id}:answered (an id longer than 64 characters is shortened with a hash). Every close.progress has its own.

close.window_opened is sent on the business day set in the business's agent_close_business_day (1 to 23, or -1 for the last business day of the month; empty means it is not sent). Set it with PATCH /api/v1/accounting-periods/settings or the update_accounting_period_settings tool. A month with fewer business days than that opens on its last one.

json
{
  "event": "pending_change.decided",
  "business_id": "0198a2c4-0000-7d3a-9a51-3f6f0c1d2e00",
  "emitted_at": "2026-10-06T14:02:11.418203+00:00",
  "pending_change_id": "0198a2c4-2222-7d3a-9a51-3f6f0c1d2e22",
  "tool_name": "categorize_transaction",
  "status": "applied",
  "round_id": null,
  "decided_by": "pat@example.com",
  "note": "Looks right"
}

What a delivery looks like

Each delivery is a POST to your URL. The body is the event's payload, exactly as listed above: one flat JSON object whose event field names the type — there is no wrapper around it (a credit event uses the credit envelope instead, see Partner Credit Lines). The headers are:

Header Value
Content-Type application/json
X-DayZero-Event The event type, e.g. close.progress
X-DayZero-Delivery-Id The delivery's id. The same on every retry and redelivery, so use it to drop repeats. Events sent once per record carry the record's key described above
X-DayZero-Timestamp When this attempt was sent, in unix seconds
X-DayZero-Signature v1= followed by the hex HMAC-SHA256 of {timestamp}.{raw body}, keyed with the endpoint secret
X-DayZero-Signature-Previous Only during the overlap after a secret rotation (at most an hour): the same signature keyed with the old secret

Verify deliveries

python
import hashlib
import hmac
import time


TOLERANCE_SECONDS = 300


def verify(secret: str, headers, raw_body: bytes) -> bool:
    timestamp = headers["X-DayZero-Timestamp"]
    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return False
    expected = (
        "v1="
        + hmac.new(
            secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
        ).hexdigest()
    )
    candidates = [
        headers.get("X-DayZero-Signature"),
        headers.get("X-DayZero-Signature-Previous"),
    ]
    return any(c and hmac.compare_digest(c, expected) for c in candidates)

Compute the HMAC over the bytes exactly as received — re-serialising the JSON will change the digest. The timestamp is inside the signature, so refuse one more than five minutes from your clock; a captured request cannot be replayed with a fresh timestamp. Each retry is signed again with a new timestamp. Respond with any 2xx at once to acknowledge, and do the work after: an attempt has 15 seconds in all, from looking up your host to the first 1 KB of your answer, and one that runs past it is stopped and counted as a failed attempt. Only that first 1 KB of the answer is read. DayZero never follows redirects, so a 3xx counts as a failure.

Retries and delivery history

A delivery is sent once the operation that produced the event has been saved, normally within a second, so the record it describes can always be fetched when your endpoint is called; an operation that fails and is rolled back sends nothing. Until its first attempt a delivery is pending, and every minute any delivery still pending ten seconds after it was written is sent. While an attempt is being made the delivery is sending; nothing else sends it meanwhile, and an attempt DayZero could not finish (it stopped mid-send) is counted as failed two minutes on and retried on the schedule below. A slow endpoint does not hold up other endpoints' deliveries.

A non-2xx response or a network failure marks the delivery retrying and re-sends it with exponential backoff — after 1 minute, 5 minutes, 30 minutes, 2 hours, then 8 hours. After six attempts in total it becomes failed. Retries carry the same body, the same X-DayZero-Delivery-Id and a fresh timestamp and signature, so make your handler idempotent (key on the delivery id or the record IDs in the payload). Deactivating or deleting the endpoint gives up its queued retries (the delivery becomes failed).

bash
curl "https://api.ondayzero.com/api/v1/webhooks/o2c/ENDPOINT_UUID/deliveries?status=failed&limit=50" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID"

Deliveries are listed oldest first. Filter with status, event_type and since (a timestamp; deliveries recorded at or after it). Each delivery reports event_type, status (pending, sending, succeeded, retrying, failed, or no_subscribers when an event fired while no active endpoint subscribed to it and nothing was sent), attempts, the last response_code, the first 1 KB of response_body, error, latency_ms, delivery_key (the X-DayZero-Delivery-Id value) and next_retry_at while an attempt is queued. The list is cursor paginated with cursor and limit (default 50, maximum 100).

POST /api/v1/webhooks/o2c/{endpoint_id}/deliveries/{delivery_id}/redeliver sends a delivery again during the call, as a new delivery linked to the original by redelivery_of, and returns it with the attempt's outcome: status (succeeded, or retrying with the receiver's response_code or the error), response_code and attempts. An original still waiting to be sent or retried is stopped and marked failed; one being sent at that moment is refused with 409 (CREDIT_646) until its attempt has finished.

Test and manage

bash
# Send a synthetic event to this one endpoint only (it must be active)
curl -X POST "https://api.ondayzero.com/api/v1/webhooks/o2c/ENDPOINT_UUID/test-fire?event_type=invoice.sent" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID"

Test payloads look like {"event": "invoice.sent", "test": true, "emitted_at": "...", "business_id": "..."} and are signed like real ones. No other endpoint on the business receives a test, so endpoints_notified is always 1. The attempt is made during the call, so the response also carries its status, response_code and the delivery_id to look up. A credit event type is sent in the credit envelope instead (see Partner Credit Lines). GET /api/v1/webhooks/o2c lists endpoints (cursor paginated, limit default 50, maximum 100), GET /api/v1/webhooks/o2c/{endpoint_id} fetches one, and DELETE /api/v1/webhooks/o2c/{endpoint_id} removes it along with its delivery history.

PATCH /api/v1/webhooks/o2c/{endpoint_id} changes url, replaces event_types (at least one) or sets is_active; omitted fields stay as they are. Deactivating stops deliveries and gives up queued retries.

bash
# Mint a new secret (returned once). The old secret keeps signing for overlap_minutes
curl -X POST "https://api.ondayzero.com/api/v1/webhooks/o2c/ENDPOINT_UUID/rotate-secret" \
  -H "Authorization: Bearer dz_your_token_here" \
  -H "x-business-id: YOUR_BUSINESS_ID" \
  -H "Content-Type: application/json" \
  -d '{"overlap_minutes": 30}'

overlap_minutes is 0–60 (default 60). While the overlap runs, each delivery also carries X-DayZero-Signature-Previous, so you can deploy the new secret without dropping events; 0 retires the old secret at once. previous_secret_expires_at on the endpoint shows when the overlap ends.

Webhook emission is best-effort: a failing receiver never blocks the underlying business operation, so treat webhooks as a nudge and the REST API as the source of truth.

Inbound Webhooks (integrations)

DayZero receives event notifications from connected services at /api/v1/webhooks/{provider}. These endpoints take no Bearer token — each request is authenticated by the provider's own signature scheme — and they are not something API consumers call.

Source Endpoint What DayZero listens for
Stripe /api/v1/webhooks/stripe invoice.finalized, invoice.paid, invoice.voided, payout.paid, payout.failed / payout.canceled — see Stripe
Shopify /api/v1/webhooks/shopify payouts/create, payouts/update, disputes/create, disputes/update, app/uninstalled and the GDPR topics. Orders and products are pulled on a schedule, not pushed — see Shopify
Square /api/v1/webhooks/square payment.completed, payment.updated, payout.sent, refund.created, refund.updated, catalog.version.updated, inventory.count.updated — see Square
Ramp /api/v1/webhooks/ramp transactions.* and bills.* events — see Ramp
Plaid /api/v1/webhooks/plaid Bank connection updates, new-transaction notifications, reauthorization required — see Plaid

Processing follows the same pattern for each: verify the signature (Stripe's Stripe-Signature, Shopify's X-Shopify-Hmac-Sha256, Square's x-square-hmacsha256-signature, Ramp's per-subscription signing secret, Plaid's Plaid-Verification JWT), store the event, then update records or nudge the relevant summary posting. Per-sale events are deliberately not posted one journal entry at a time — the ledger only sees daily or per-payout summaries.

Common errors

Status Cause Fix
400 url is not HTTPS or resolves to a private, internal or cloud-metadata address Use a public HTTPS URL
400 (CREDIT_101) An event_types entry is not a listed event or a credit.* event Fix the name the error quotes
400 PATCH with an empty event_types list Send at least one type
409 (CREDIT_646) redeliver on a delivery whose attempt is still in flight Wait for the attempt to finish, then redeliver
422 secret shorter than 32 characters, overlap_minutes outside 0-60, or limit above 100 Fix the field