Skip to main content
Sign in

Docs

Your system makes automated decisions about people. Azul keeps the record of those decisions in a provable form, and communicates that record to the people your service decides about.

Two words come up throughout these docs. The developer is the company that integrates Azul: you. The deployer is a company or team that works through the cases your product creates, like an employer who uses a recruiting platform. Often those are two companies, but they can be one: your own operations team working your own cases is a deployer too. Azul works with the developer, and everything deployers use is a page you issue or build with Azul's APIs.

Deciding something about a person with software carries obligations. The rules differ by jurisdiction, but the requirements underneath are the same:

Opt out
Let the person refuse automated processing, before or during the decision, and prove the refusal was honored.
Record
Show what was decided, by what, on what basis, and in what order.
Explain
Tell the person affected what happened, and prove they were told.
Review
If a person says the result is wrong, make a qualified human do the job and show who that was and what they actually looked at.

All of these tasks run through the Azul API: keeping the record, serving explanations, and managing human reviews.

What you integrate

Decision records, required
A decision made by an automated process has to be captured as it happens, so this integration lives in your codebase. Connect each step of the decision process, automated or human, to the API where it is recorded.
Default pages
We provide default pages for pre-submission, post-submission, the reviewer session, and deployer management. You, the developer, issue them to deployers and customize them for each.
Your own UI
Other than the post-submission page and the reviewer session, every page is built on public endpoints, so you, the developer, can build your own UI for the same functionality.
The dashboard
If you are acting as both the developer and the deployer, you can publish policies, inspect sessions, and review jobs from the dashboard.

Getting started

Five steps take the system from nothing to a recorded, explainable decision.

1 · Workspace
Sign in at azul.so/app. Your first sign-in creates the workspace: the dashboard, its settings, and everything below hang off it.
2 · API key
Settings, then API keys. Mint a key scoped to what the integration does: recording decisions needs the record scopes, issuing pages needs the explanation scopes. The raw key is shown exactly once.
3 · First decision
From your backend, publish a policy and a flow, then run one session per decision. Recording a decision walks every call with its response; the same calls appear in each page section below, prefilled by the builders.
4 · Pages
Give the affected person their post-submission page (issued automatically at session creation; the dashboard and API can re-issue it), put the opt-out element in your form, and hand each deployer their management link.
5 · Notifications
Operational alerts live in Account notifications. Deployer review alerts and personal preferences live in the management portal. Assigned outside reviewers receive a separate, email-verified review page.

Everything in these docs runs in test mode first: every page section has a live builder and example, and any values are accepted. Nothing in a test view records anywhere.

The case model

A case is one decision about one subject: a single job application for a candidate, or a loan application for a couple. It has three parts: the chain that produced the outcome, the explanations issued back to the person through the post-submission page, and the contest and review process they can take up. A case also carries what governs it: its policy, its opt-out state, and, arriving with partitions, which deployer it belongs to.

A policy holds the rules cases run under. This includes what the chain must satisfy before it can finalize (which outcomes are allowed, whether a person must review, which checks must run, how long evidence is kept) and what is offered to the person (the opt-out, the identity verification, the contest window). Every case tracks the policy that governs it. Policies come from Azul's presets, from you the developer, or from a deployer's own policy manager.

Then, before any case, the opt-out. Where the policy offers it, the subject can refuse automated processing in your form before submitting, for that one decision or as a standing preference against the deployer: the session is created already opted out, automated steps are refused from the first event, and a person handles the request. Only when they proceed, or until they opt out mid-decision, does an automated case run.

The chain is the part that lives in your code: the session, the runs of each step inside it, the evidence and human contributions those runs carry, and the decision that closes it. Everything else in Azul reads what the chain recorded. A partition, arriving with deployers as first-class records, stamps which deployer a case belongs to.

A contest is the person saying the decision is wrong and asking a human to review it. Filing one moves the case from decided to contested, and the case has no settled answer until the final review is complete. A contest can be reviewed by several people, as the governing policy requires, and the policy can restrict who makes the final call. The final review's result becomes the case's effective outcome. Every decision, including the contested one, stays on the record.

Every name below is the name the API uses; open a row for what it holds and the call that creates it.

The case model

A subject is a person, or a unit the decision affects together like a household or a company. If your process runs twice for the same person that is two cases, because each run produced its own outcome and each outcome can be contested on its own. A case is addressed by its session id.

Recording a decision

This is the integration, and it has four moves: set up a policy and a flow, publish them so they cannot change, run one session per decision saving each step as it happens, and finish with the final answer and a receipt. Everything else in Azul reads what these calls write.

They take a project API key, minted in your workspace settings and shown once. A key holds only the scopes you give it, so a key that records chains cannot read cases, issue links, or change rules unless you scoped it to. Revoke freely: revoking never affects what a key already recorded.

# Machine plane. Authorization: Bearer da_live.<prefix>.<secret>
# Every write: Idempotency-Key: <one key per logical write>
1 · Set up and publish, once
POST /v2/policies
{ "key": "hiring-2026", "name": "Hiring policy",
  "decision": { "allowed_outcomes": ["advance", "decline"] },
  "human_review": { "required": true, "minimum_reviewers": 1 },
  "evidence": { "retention_days": 730 },
  "required_jobs": ["bias_check"] }
-> { "resource": { "key": "hiring-2026", "draft_revision": 1 } }

POST /v2/policies/hiring-2026/publish            X-Expected-Revision: "1"
{ "version": "1.0" }
-> { "version": "1.0", "document_hash": "..." }

# The process. Never put a provider secret in a flow definition.
POST /v2/flows
{ "key": "candidate-screening", "policy_version_id": "...",
  "risk_tier": "consequential", "significant_decision_type": "employment",
  "nodes": [ { "key": "resume_score", "type": "automated.llm",
               "disclosure": { "summary": "Compares the resume with the job rules.",
                               "how_used": "Helps a person review. Cannot reject." },
               "inputs": { ... }, "outputs": { ... } }, ... ] }
-> { "resource": { "key": "candidate-screening", "draft_revision": 1 } }

POST /v2/flows/candidate-screening/publish       X-Expected-Revision: "1"
{ "version": "1.0" }
-> { "version": "1.0", "document_hash": "..." }
2 · Start the session, per subject
POST /v2/sessions
{ "flow": "candidate-screening", "flow_version": "1.0",
  "subject": { "external_id": "candidate-1842" },
  "purpose": "Review candidate 1842 for job ENG-42.",
  "jurisdiction": "US-CA" }
-> { "session": { "id": "...", "revision": 1 }, "nodes": [ ... ] }
3 · Record each step, as it runs
POST /v2/sessions/{session_id}/nodes/resume_score/runs
{ "inputs": { "resume": { "kind": "inline", "value": "..." } } }
-> { "run": { "id": "...", "revision": 1, "status": "running" } }

PATCH /v2/node-runs/{run_id}                     X-Expected-Revision: "1"
{ "outputs": { "score": { "kind": "inline", "value": 0.87 } },
  "status": "completed",
  "execution": { "requested_provider": "openai",
                 "requested_model": "gpt-5-mini" },
  "explanation": "The score follows the job rules." }
-> { "run": { "id": "...", "revision": 2, "status": "completed" } }

# When a person acts, they act as themselves
POST /v2/node-runs/{run_id}/human-contributions  X-Expected-Revision: "1"
{ "kind": "decision",
  "actor": { "external_id": "reviewer-71", "name": "A. Reviewer",
             "role": "hiring_manager" },
  "decision": { "outcome": "advance",
                "explanation": "The candidate meets the job rules." } }
-> { "contribution": { "id": "...", "kind": "decision" } }
4 · Finalize and keep the receipt
# Checked against the policy: allowed outcome, required review, required jobs.
POST /v2/sessions/{session_id}/finalize          X-Expected-Revision: "1"
{ "decided_by_node_run_id": "...", "outcome": "advance" }
-> { "session": { "status": "finalized" }, "receipt": { "event_hash": "..." } }

# Keep this with your own decision record
GET /v2/sessions/{session_id}/receipt
-> { "session_id": "...", "event_hash": "...", "previous_hash": "..." }

What the API enforces

Order
No run after finalize, and finalize only once — and only when the policy is satisfied: an allowed outcome, the required human review, the required jobs. Every event is hash linked, so the record cannot be edited afterwards without breaking the chain.
Idempotency
Every write carries an Idempotency-Key header, so a retry after a timeout records once rather than twice. Reusing a key for a different write is refused with a 409.
Versioning
A published flow or policy version is immutable, and a session pins the version it started with. Publish a new one and old cases keep reading against the definition that governed them.
Revisions
Nothing is overwritten. A correction appends a new revision with a change_reason, guarded by X-Expected-Revision: a stale writer gets a 412 instead of clobbering, and both versions stay on the record. On a 412, re-read the record and decide again against what is actually there.
Classification
Evidence declares what it is and how much of it to keep. Sensitive values can be stored as a digest only and still be provable later.

Pre-submission

The consumer's other right, and it divides over submission, because submission is the moment identity begins. Before it, nothing is known about the person, so the opt-out cannot be a page: it is the element in your form, the pre-use notice plus an unchecked opt-out control. What makes the choice provable is a correlation token. The element mints one when it mounts and records events against it: notice shown, opt-out checked, unchecked. It also drops the token into your form as a hidden input, so your existing form post carries it to your backend, and you pass it when the session is created. The join happens on our side: the case is born opted out, and the chain's first events prove the notice preceded the processing. Time is never the join key, so the seconds between the click and the submit do not matter.

Checking the box opens two choices, and they are different transactions. This decision only, the default, is one click and collects nothing: it is form state riding the token, it takes effect at submission, and someone who checks it and never submits produced no data and needs no record. All automated decisions from this company is a standing request, so it needs a durable key: one contact field, one verification code, nothing else. That flow runs on a hosted surface the element opens and reports back from, it completes whether or not the form is ever submitted, and a receipt goes to the verified contact. The receipt carries the link for changing the choice later: the element itself never manages status, the portal does.

Verification returns an opaque subject contact hash minted by Azul. Save that exact value with the form result and send it as subject.contact_hash on this and future v2 sessions for that contact. Do not calculate your own hash: it cannot match Azul's protected tokenization key. Direct component embeds receive hidden fields; iframe embeds receive the same state in the azul-opt-out postMessage callback. Email OTP is available now; only show phone OTP after your Azul deployment has an SMS provider configured.

For significant decisions the regulation permits an alternative: instead of an opt-out, a human appeal by a qualified reviewer who can overturn. That mode resolves through the contest and review flow you already have, and the element renders it where the policy selects it.

Two rules shape the element. Collection scales to the request: the per-decision choice collects nothing, and the standing one collects exactly the one verified contact it cannot exist without. And the full opt-out is never harder than any subset: a company may add narrower options, but refusing everything stays the shortest path.

Below is the element in a host form: drag it to move, pull an edge or corner to resize, and the content follows the box, so it sits equally well in a footer, a banner, or a card.

Try the parameters
BrandingAcme Hiring · named in the copy only

The element carries no logo or brand header. The company name appears in the notice and choice copy; the accent colors the controls.

ModeOpt-out
Standing opt-outverify by email or SMS

The standing tier needs one verified contact as its durable key. One field, one code, nothing else.

Deployer optionsnone added

Narrower choices a company may add, e.g. "Auto-resume review only". The full opt-out always stays the shortest path; extra options can only ever be narrower.

Test mode. These settings live in this browser only and change nothing anywhere else. On a real link a bound policy overrides everything that is not cosmetic.

The API call
# The element mints a correlation token when it mounts and records
# events against it: notice shown, opt-out checked, unchecked. It drops
# the token into your form as a hidden input, so your existing form post
# carries it to your backend. Iframe embeds receive the same values in
# the azul-opt-out postMessage callback.
POST https://api.azul.so/p/presubmission/tokens
-> { "token": "pst_...", "expires_at": "..." }

# Your backend passes it at session creation. The join happens server
# side: the case is born opted out and the chain's first events prove
# the notice preceded the processing.
POST https://api.azul.so/v2/sessions        (machine plane, with your key)
{ "flow": "candidate-screening", "presubmission_token": "pst_...",
  "opt_out": true, ... }

# The standing opt-out ("all automated decisions from this company")
# runs on a hosted surface the element opens: one contact field, one
# verification code. It completes whether or not the form is submitted,
# and the receipt carries the link for changing the choice later.
# The verified response returns Azul's opaque stable contact_hash. Persist
# that value with the form submission; do not hash the email yourself.
POST https://api.azul.so/p/admt/preferences/request-code
{ "project_id": "...", "channel": "email", "contact": "person@example.com" }
POST https://api.azul.so/p/admt/preferences/verify
{ "challenge_id": "...", "code": "123456" }
-> { "verified": true, "contact_hash": "<opaque 64 hex>",
     "management_url": "https://azul.so/oo/manage/..." }

# Enforced at session create against the deployer's preference store:
{ ..., "subject": { "external_id": "cand_8412",
                    "contact_hash": "<opaque value returned by Azul>" } }

# Email delivery is live. Do not render the phone option until an SMS OTP
# provider is configured for the Azul deployment.

# mode "opt_out": the subject may refuse automated processing; further
# automated steps for them are rejected per policy.
# mode "appeal": the element renders the human-appeal alternative
# instead, which resolves through the contest and review flow.
# branding: {"name":"Acme Hiring","accent_color":"#111111"}

Post-submission

Everything the subject sees once a case exists, through every state the case passes. It is one page with one link for the whole lifecycle, the way a billing portal is one page: the content changes as the case does. It is reached by an explain link: a token in a URL, opened without an account. Send it by your own email, your own SMS, or put it behind a button in your product.

Decision pending
Live status while the chain runs. The opt-out is still open here, and taking it stops automated steps mid-decision.
Decision made
The explanation, the factors where the policy shows them, and the contest offer inside its window. The standing opt-out (all automated decision making from the company) stays editable here even after the decision.
Contest filed
The contest's progress: filed, assigned, under review, resolved. When a final review lands, its decision shows as the case's effective outcome.
Opt-out submission
Post-submission includes post-opt-out submission. A case born opted out gets the same page: it shows the choice on record, the human path handling the request, and where the standing preference is managed.

Verification runs before any decision material is served, so an intercepted link on its own shows nothing. If the page offers to contest, that is where a contest comes from.

Issuing a link takes its own authority. From the dashboard, a signed-in workspace user issues them by hand. From your backend, a project API key does it only when scoped to: explain:write for explanation links, contest:write for review links and deployer pages. A key scoped to record chains can do neither. The link you hand over is a page token: the whole credential for that one page, carrying no workspace access, and revocable without touching the case. Every distributable page works this way.

https://azul.so/e/<token>    a subject's explanation
https://azul.so/r/<token>    a reviewer's session
https://azul.so/m/<token>    a deployer's queue

Change anything below. Both the call and the example page update.

Try the parameters
PreviewJordan Rivera · Decision made
Case state

One link, one page: the content follows the case. The states here are what the same URL shows as the case moves.

BrandingAcme Hiring · initial mark
Logo branding.logo_url
A

Shrunk and kept in this browser, never uploaded. On a real link you pass a URL as branding.logo_url.

Identity verificationany 1 of Email code, Text message code
Identity evidence on file identity.fields
Email codeemail · code by email
Text message codephone · code by text
Full namename · shown to the subject
Date of birthdob · matched against what you file
Home addressaddress · matched against what you file
Reference numberreference · matched against what you file
Requirement identity.require
The subject completesanyof 2any 1 of Email code, Text message code

Every requirement must be satisfied; each is any k of its ticked fields, so "3 of these 5 pieces" is one row. Codes prove access to a value you hold, matched values prove knowledge of one, and shown fields (like the greeting name) prove nothing, so they can't be required. Declare only what you actually hold; a bound policy can replace the requirement at mint time.

Policy optionscontest 30d · no opt-out · factors · issue after final

A tracking link is issued automatically at submission. Its page shows progress until the decision is ready.

Every option here is a policy field. A bound policy replaces these at mint time; the builder shows what the page does under each rule.

Test mode. These settings live in this browser only and change nothing anywhere else. On a real link a bound policy overrides everything that is not cosmetic.

The API call
POST https://api.azul.so/v1/admin/sessions/{session_id}/explain-links
Authorization: Bearer <dashboard token>

{
  "identity": {
    "fields": [
      {
        "key": "email",
        "label": "Email code",
        "method": "otp_email",
        "value": "<on-file value>"
      },
      {
        "key": "phone",
        "label": "Text message code",
        "method": "otp_sms",
        "value": "<on-file value>"
      },
      {
        "key": "name",
        "label": "Full name",
        "method": "display",
        "value": "Jordan Rivera"
      }
    ],
    "require": [
      {
        "any": 1,
        "of": [
          "email",
          "phone"
        ]
      }
    ]
  },
  "flow": {
    "issue_timing": "after_final",
    "allow_contest": true,
    "contest_window_days": 30,
    "opt_out": "none",
    "show_factors": true
  },
  "branding": {
    "name": "Acme Hiring",
    "accent_color": "#111111"
  }
}

-> { "url": "https://azul.so/e/<token>", ... }

# Link issuance is 'after the final decision': minting before the
# chain finalizes is rejected with 422.
#
# Issuing runs on the dashboard plane. The page the subject then opens runs on
# the page plane: GET /p/explain/{token}, which is what you would build your
# own subject page against.

The review session

One reviewer, one review, on the record. A review link binds to a named person and opens a session that captures what they did while they did it.

Two things have to be provable afterwards: that a person actually did the work, and who that person was. Reviewers do not get Azul seats, because most of them are not your staff. They are authenticated against our backend instead, by a code sent to the email the link was issued to. Proving they are qualified is your burden, not ours. What we do is make the claim explicit, attributed, and dated.

Try the parameters
PreviewJordan Rivera
BrandingAcme Hiring
Logo branding.logo_url
A

Shrunk and kept in this browser, never uploaded. On a real link you pass a URL as branding.logo_url.

Evidence capturewebcam · mic · reads

Test mode. These settings live in this browser only and change nothing anywhere else. On a real link a bound policy overrides everything that is not cosmetic.

The API call
POST https://api.azul.so/v1/admin/contests/{contest_id}/review-links
Authorization: Bearer <dashboard token>

{
  "kind": "final",
  "reviewer_email": "reviewer@example.com",
  "evidence_policy": {
    "webcam": true,
    "mic": true,
    "screen": false,
    "track_reads": true,
    "require_all_materials": true
  },
  "branding": {
    "name": "Acme Hiring",
    "accent_color": "#111111"
  }
}

-> { "url": "https://azul.so/r/<token>", "auth_method": "email_otp", ... }

# kind "sub" records an advisory review toward the policy's quorum.
# kind "final" resolves the contest.
# A bound policy overrides the evidence policy above, and can require the
# reviewer to verify a code before any material is served.
#
# The reviewer's page runs on the page plane:
#   GET  /p/review/{token}                 what they may see
#   POST /p/review/{token}/reviewer/verify identity check
#   POST /p/review/{token}/events          capture and read tracking
#   POST /p/review/{token}/submit          the decision

Management

The queue, for a deployer with no Azul seats. The developer hands each deployer a scoped link: they see only their own contests, assign reviewers by email, and mint review links inside the capabilities the grant carries.

The link opens one management page. Policies and Team are sections of the same page, and each appears only when the grant carries its capability, so what a deployer sees in the sidebar is exactly what their grant can do.

The work usually does not happen where the decisions do. A hiring platform may hold a hundred thousand contests and be the right party to resolve none of them. Each of its deployers reviews their own, every deployer runs a different process, and the developer has to support all of them without giving anyone an account.

capabilities
view, assign, review:sub, review:final, policy:manage, team:manage. The token can do exactly these and nothing else.
workflow_slugs
Which pipelines the grant can see. null means all of them.
branding
name, logo_url, accent_color. Per grant, so each deployer's page looks like theirs.
expires_at
Optional end date. Revoking is immediate and does not touch the cases the grant could see.

Minting a final review through a grant needs one more thing: the governing policy has to allow it. A grant with review:final still cannot resolve a contest whose policy keeps resolution with the workspace. Capability and authority are deliberately two different questions.

Try the parameters
BrandingAcme Recruiting · travels with the grant
Logo branding.logo_url
A

Shrunk and kept in this browser, never uploaded. On a real link you pass a URL as branding.logo_url.

Branding travels with the grant, so every deployer can carry its own name, logo, and accent. What the page is allowed to do comes from the grant's capabilities and the governing policy, not from here.

Test mode. These settings live in this browser only and change nothing anywhere else. On a real link a bound policy overrides everything that is not cosmetic.

The API call
POST https://api.azul.so/v1/admin/delegations
Authorization: Bearer <dashboard token>

{
  "name": "Acme Recruiting (hiring team)",
  "workflow_slugs": [
    "contract_roles",
    "full_time_roles"
  ],
  "capabilities": [
    "view",
    "assign",
    "review:sub",
    "review:final"
  ],
  "expires_at": null,
  "branding": {
    "name": "Acme Recruiting",
    "accent_color": "#111111"
  }
}

-> { "url": "https://azul.so/m/<token>", ... }    shown once, then never again

# workflow_slugs: null grants every workflow. Branding is per grant, so each
# deployer can carry its own name, logo, and accent.
# Minting a final review through a grant also requires the governing policy's
# resolvable_by to be "delegate" or "either".
#
# The deployer's page runs on the page plane, with the token as the credential:
#   GET   /p/manage/{token}                            name, scope, capabilities
#   GET   /p/manage/{token}/contests                   their queue only
#   PATCH /p/manage/{token}/contests/{id}               assign a reviewer
#   POST  /p/manage/{token}/contests/{id}/review-links  mint a review session

Policies

The page's Policies section: the same link and grant, unlocked by policy:manage. A deployer sees and tunes the rules that govern their own pipelines. One deployer runs contract roles at a 21 day window with one advisory review, another runs full-time offers at four eyes. Neither needs a seat, and neither can see the other. Every edit is versioned and attributed to the grant on the policy's event chain, so a rule change is as traceable as a decision.

The API call
GET  https://api.azul.so/p/manage/{token}/policies     any valid grant
PUT  https://api.azul.so/p/manage/{token}/policies     needs policy:manage

{ "id": null, "name": "Contract roles", "sub_reviews_required": 1, ... }

# Page plane, so the token is the whole credential and the deployer needs
# no account. The page is the same table the workspace uses, branded as
# "Acme Recruiting" and scoped to what the grant can see.
# Every edit is versioned and attributed to the grant on the policy's
# event chain.

The team

The page's Team section, unlocked by team:manage: who at the deployer can do what. Their admin adds people by email and sets each person's capabilities, bounded by the grant. Nobody gets an Azul account: each person verifies by a code and carries their own audit trail, so the record says who assigned, who reviewed, and who changed a rule.

The API call
GET  https://azul.so/tm/<token>            (coming with deployer principals)

# The grant's admin adds people by email. Each person becomes a principal:
# no account, verified by a code, capabilities of their own, and their own
# audit trail. Capabilities can never exceed the grant's ("Acme Recruiting").

Real grants are created in the dashboard, or by the calls above. The link is shown once and is the credential, so treat it like one. Revoke a grant with DELETE /v1/delegations/{delegation_id}: immediate, and it never touches the cases the grant could see.

Test mode