Every action has a clear state

An action moves through a deterministic state machine. No ambiguity. No intermediate states. Every transition is permanent.

Action State Machine
pending before active_at
active_at reached
active consumable
POST .../consume
consumed permanent
expires_at elapsed
expired permanent
DELETE .../actions/:id
expired canceled

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.

Create
POST /v1/actions

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.

POST /v1/actions → 201
// 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.

Verify
GET /v1/actions/:id

Returns the current state of the action. Safe to call repeatedly — idempotent and read-only.

GET /v1/actions/act_7f3k9mZn4pQ → 200
{
  "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.

Consume
POST /v1/actions/:id/consume

On success, returns 200 with the payload and audit data. On any failure, returns a specific typed error — no guessing required.

Possible responses
// ✓ 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.

Concurrent consume requests — same action ID Without ExactOnce With ExactOnce
❌ Ad-hoc Redis / DB check
t=0ms
Request A: GET token → active
t=1ms
Request B: GET token → active
t=3ms
Request A: SET consumed → ok
t=4ms
Request B: SET consumed → ok
t=5ms
Both succeed. Action fires twice.
✓ ExactOnce conditional write
t=0ms
Request A: atomic conditional write
t=1ms
Request B: atomic conditional write
t=3ms
Request A: 200 OK → consumed
t=3ms
Request B: 409 → already_used
t=4ms
One winner. One clear loser. Always.

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.

Client ID
A stable identifier for your application. Safe to log. Used to scope actions to your account.
client-id: cli_abc123
Client Secret
Treat like a password. Never expose in client-side code. Rotate from the dashboard at any time.
client-secret: sk_live_••••••••
Every request
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.

Create with PIN
{
  "payload": { "action": "transfer" },
  "pin":     "847291",
  "expires_at": "2026-02-19T02:00:00Z"
}

// → 201, actionId returned
// PIN is hashed server-side.
// Never returned in responses.
Consume with PIN
// 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.

POST /v1/actions — Content-Type: text/csv
# 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.

What happens if two requests consume at exactly the same time?
Exactly one wins. The conditional write at the database level serializes concurrent requests at the storage layer — not the application layer. The losing request receives a 409 already_used response. This is deterministic regardless of how many concurrent requests arrive simultaneously.
Can I cancel an action before it's consumed?
Yes. 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.
What if I need the same action to be consumable more than once?
ExactOnce isn't the right tool for that. If you need multi-use tokens, a traditional signed JWT or session-based approach is more appropriate. ExactOnce is deliberately single-use only — if your use case needs weaker guarantees, you don't need this product.
Is the payload stored securely? Can I put sensitive data in it?
Payloads are stored encrypted at rest. That said, we recommend treating actionIds as opaque tokens and keeping truly sensitive data (SSNs, raw passwords, credit card numbers) out of payloads. Store a reference ID instead — e.g. user_id — and look up the sensitive data server-side after consuming the action.
What happens to my data after the action expires?
Expired actions are retained for audit purposes for the duration of your plan's retention window. After that, they are permanently deleted. The state record (without payload) may be kept longer for compliance reporting. You can also manually delete actions via the API.
Can I get notified when an action is consumed?
Webhooks are on the roadmap for the next release. For now, you can poll 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.
How do I filter and list actions by state or date?
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.

Ready to stop rolling your own?

Private beta is free. We just want your feedback.

Use Cases → Request Access