Every action has a clear state
An action moves through a deterministic state machine. No ambiguity. No intermediate states. Every transition is permanent.
Terminal states are immutable — a consumed or expired action cannot be reset.
Create an Action
You define the payload, the activation window, and the expiry.
ExactOnce stores it and returns an actionId.
Send a JSON body with your payload (arbitrary key-value data),
an optional active_at (schedule it), and an
expires_at deadline. The action will only be consumable
within that window.
// Request { "payload": { "action": "password_reset", "user_id": "usr_abc123", "email": "alex@example.com" }, "active_at": "2026-02-19T00:00:00Z", "expires_at": "2026-02-19T01:00:00Z" } // Response — 201 Created { "actionId": "act_7f3k9mZn4pQ", "activeAt": "2026-02-19T00:00:00Z", "expiresAt": "2026-02-19T01:00:00Z" }
Verify State (Optional)
Read-only. Check what state an action is in before deciding to present a UI or gate access. This does not consume the action.
Returns the current state of the action. Safe to call repeatedly — idempotent and read-only.
{
"actionId": "act_7f3k9mZn4pQ",
"state": "active",
"activeAt": "2026-02-19T00:00:00Z",
"expiresAt": "2026-02-19T01:00:00Z",
"pinRequired": false
}
Consume the Action
This is the critical path. One request wins. All others get a typed error. The state transition is atomic — backed by a database-level conditional write.
On success, returns 200 with the payload and audit data. On any failure, returns a specific typed error — no guessing required.
// ✓ 200 OK — first and only successful consume { "actionId": "act_7f3k9mZn4pQ", "state": "consumed", "payload": { "action": "password_reset", ... }, "consumedAt": "2026-02-19T00:14:33Z" } // ✗ 409 — already used { "error": "already_used" } // ✗ 410 — expired { "error": "expired" } // ✗ 404 — not found { "error": "action_not_found" } // ✗ 409 — before active window { "error": "not_active" }
How atomicity actually works
ExactOnce uses a database-level conditional write to guarantee exactly-once semantics. No distributed locks. No queues. No "maybe" states. The database serializes concurrent requests — one wins, the rest fail the condition check.
The write condition is: activeAt <= now < expiresAt, not consumed, not canceled.
Only one concurrent thread can transition this to consumed.
The database enforces this — not application code, not locks, not hope.
Simple header-based auth
Every request is authenticated with a client-id and
client-secret header pair.
Keep your secret server-side — never expose it to the browser.
curl -X POST https://api.exactonce.com/v1/actions \ -H "client-id: cli_abc123" \ -H "client-secret: sk_live_yourSecretHere" \ -H "Content-Type: application/json" \ -d '{"payload": {"action": "invite"}, "expires_at": "..."}'
Add a second factor
Attach a PIN to any action. The consumer must provide the correct PIN to consume it. Wrong PIN? The action stays active until the third failed attempt, then it is burned and cannot be consumed.
{
"payload": { "action": "transfer" },
"pin": "847291",
"expires_at": "2026-02-19T02:00:00Z"
}
// → 201, actionId returned
// PIN is hashed server-side.
// Never returned in responses.
// POST /v1/actions/:id/consume { "pin": "847291" } // Wrong PIN → 401 { "error": "invalid_pin" } // After 3 failed attempts → burned, no longer consumable // Correct PIN → 200, consumed
A wrong PIN does not consume the action. You can combine PIN protection with any action type — magic links, approvals, file downloads, and more. Useful when the actionId itself might be shared (e.g. a public URL) and you need an additional identity check.
Create thousands at once
Need to issue 5,000 invite codes or one-time promo links at once? Send a CSV instead of JSON. Same guarantees, at scale.
# sample-actions.csv payload_json,pin,active_at,expires_at "{\"type\":\"invite\",\"user_id\":\"usr_001\",\"email\":\"alice@example.com\"}",,2026-03-01T00:00:00Z,2026-03-08T00:00:00Z "{\"type\":\"invite\",\"user_id\":\"usr_002\",\"email\":\"bob@example.com\"}",,2026-03-01T00:00:00Z,2026-03-08T00:00:00Z "{\"type\":\"promo_code\",\"user_id\":\"usr_003\",\"email\":\"carol@example.com\"}",9823,,2026-02-28T00:00:00Z // Response → 200, per-row results { "total": 3, "created": 3, "failed": 0, "results": [ { "row": 2, "status": "created", "actionId": "act_..." } ] }
Questions we'd ask too
The scenarios that matter in production — answered clearly.
already_used response. This is deterministic regardless of how many concurrent requests arrive simultaneously.
DELETE /v1/actions/:id marks an unconsumed action as canceled. Any subsequent consume attempt returns a 410 canceled. You cannot cancel an already-consumed action.
user_id — and look up the sensitive data server-side after consuming the action.
GET /v1/actions/:id or use the GET /v1/stats endpoint to track consumption rates. If webhooks are critical for your use case, let us know during beta — it directly influences our roadmap.
GET /v1/actions supports query params: state (pending, active, consumed, expired), consumed_reason (consumed, invalid_pin_burned), created_from, created_to, active_from, active_to, order_by (createdAt, activeAt, expiresAt), order, and cursor-based pagination via nextToken.