Skip to main content
DocsSign 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 and holds the workspace: you. The deployer is a company or team that works through the cases your product creates, the way an employer works the applications a hiring platform screens. 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 you, the developer, and everything deployers use is a page you issue or build with Azul's APIs.

Why it is built this way: deciding something about a person with software carries obligations. The rules differ by jurisdiction and keep moving, but underneath them the requirements are stable and few.

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. This has to be built in, because nothing downstream can invent it later.
Explain
Tell the person affected what happened, in terms they can act on, and prove they were told.
Review
When they say it is wrong, put a qualified human on it and show who that was and what they actually looked at.

All of it is one API. Some calls keep the record: you add them where your process already runs. The rest serve the people the decision reaches, and for those we ship finished pages. Issue a link and hand it over, embed the page in your product, or call the same endpoints and build your own. The record is identical either way, and it is one object rather than three systems: an explanation that cannot be tied to its decision proves nothing, and a review of a decision nobody wrote down proves less.

What you integrate

The decision record, required
Five calls that record a decision as your process makes it: publish the workflow, open a session, record each step, attach evidence, finalize. Evidence has to be captured where the work happens, so this part is always in your code.
Then a choice: our default pages
Issue the pre-submission element, the post-submission page, the reviewer session, and the deployer managers as links, one call each, restyled with your name, logo, and accent. The people using them need no account.
Or your own UI on the same APIs
Every page is built on public endpoints. Build your own JSX against them, point your internal tools at them, and mix freely: default pages where they fit, your own where they don't. The record is identical either way.

The case model

Everything starts with a policy, because everything that follows consults one. A policy is the rules decisions run under: whether an opt-out is offered, what identity verification the post-submission page demands, the contest window, the review quorum, who may resolve. Policies come from three levels (Azul presets, yours, and a deployer's), bind to scopes with the most specific binding winning, and stamp their revision onto everything they govern. The full field list is in the tree below.

Then, before any case, the opt-out. Where the policy offers it, the subject can refuse automated processing in your form before submitting: 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.

A case is one decision about one subject. It has three parts, in the order they come into being: the chain that produced the outcome, the explain link that opens the post-submission page, and the contest they file if they want a human to look again. Only the chain always exists. A case also carries what governs it: its policy, its opt-out state, and, arriving with partitions, which deployer it belongs to. 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.

The contest

A contest is the subject saying the decision is wrong and asking a human to look again. One per case. Filing it moves the case from decided to contested, and the case has no settled answer again until a final review lands.

A contest can be looked at by several people, so a policy can require a number of advisory reviews before anyone may resolve, and can restrict who may cast the final one. The final review's decision becomes the case's effective outcome. An overturn does not erase the original decision, it amends the case and both stay on the record.

POST  /p/explain/{token}/contest              the subject files it
GET   /v1/admin/contests                      your queue
PATCH /v1/admin/contests/{contest_id}         assign a named reviewer
POST  /v1/admin/contests/{id}/review-links    mint a review session

GET   /p/manage/{token}/contests              a deployer's own queue

Recording a decision

This is the integration. Publish your process once, then run these calls where the decision is actually made. Everything else in Azul reads what they write.

They take a project API key, minted in your workspace settings and shown once. A key can write a chain and nothing else, so a leaked one cannot read cases, issue links, or change rules. Scope it and revoke it freely: revoking never affects what it already recorded.

# Machine plane. Authorization: Bearer da_live.<prefix>.<secret>

# 1. Once per process version
POST /v1/workflows
{ "slug": "contract_roles", "version": "2026-07-01", "purpose": "...",
  "nodes": [ { "key": "screen", "type": "ai", ... } ], "edges": [ ... ] }

# 2. Per subject, when the process starts
POST /v1/sessions
{ "workflow": "contract_roles", "subject_id": "cand_8412",
  "purpose": "Screen an application for a contract role",
  "idempotency_key": "..." }
-> { "session_id": "..." }

# 3. Per step, as it runs
POST /v1/sessions/{session_id}/steps
{ "node_key": "screen", "inputs": { "resume": { "kind": "inline", ... } },
  "idempotency_key": "..." }
-> { "step_run_id": "..." }

POST /v1/steps/{step_run_id}/complete
{ "outputs": { "score": { "kind": "inline", "value": 0.31 } },
  "explanation": "Below the threshold for required skills",
  "details": { "node_type": "ai", "provider": "...", "model": "...",
               "material_factors": ["skills_match"] },
  "idempotency_key": "..." }

# 4. Once, when the outcome is known
POST /v1/sessions/{session_id}/finalize
{ "decided_by_step_run_id": "...", "outcome": { "advance": false },
  "outcome_code": "NOT_ADVANCED", "reason_codes": ["skills_match"],
  "material_factor_evidence_ids": ["..."],
  "explanation": "The application did not meet the required skills",
  "idempotency_key": "..." }

# Read it back exactly as recorded
GET /v1/sessions/{session_id}/receipt

What the API enforces

Order
No step after finalize, and finalize only once. Steps are hash linked, so one cannot be edited afterwards without breaking the chain.
Idempotency
Every write takes an idempotency_key, so a retry after a timeout records once rather than twice.
Versioning
A published workflow version is immutable. Publish a new one and old cases keep reading against the definition that governed them.
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 part of your form, the pre-use notice plus an unchecked opt-out control, and the choice lands as a flag when the session is created. The case is born opted out and the record proves the request was honored from its first moment.

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. After submission a case exists and the choice becomes addressable: the hosted opt-out page, linked from the post-submission page while the decision runs. Someone who selects opt-out and never submits produced no data and needs no record.

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.

Two rules shape the page itself. Opting out collects nothing: the link is the identity, and ADMT bars asking for more than the request reasonably needs. And the opt-out is complete: it ends automated decision-making for the request, which is all the regulation requires. Subset opt-outs a company may choose to offer can come later.

Try the parameters
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.

Mode

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
# Before submission there is no page: the choice is part of your form
# and rides the submission as a flag. The case is born opted out and
# automated steps are refused from step one.
POST https://api.azul.so/v1/sessions        (machine plane, with your key)
{ "workflow": "contract_roles", "subject_id": "cand_8412",
  "opt_out": true, ... }

# After submission a case exists, so the choice is addressable: mint the
# hosted page and link it from the status view.
POST https://api.azul.so/v1/admin/opt-out-links   (coming with the opt-out backend)
Authorization: Bearer <dashboard token>

{
  "subject_id": "cand_8412",
  "mode": "opt_out",
  "branding": {
    "name": "Acme Hiring",
    "accent_color": "#111111"
  }
}

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

# mode "opt_out": the subject may refuse automated processing; further
# automated steps for them are rejected per policy.
# mode "appeal": the page explains the human-appeal alternative instead,
# which resolves through the contest and review flow.

Post-submission

Everything the subject sees once a case exists: live status while the decision runs, the explanation and factors when it is made, the contest path, and the opt-out while it is still open. 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.

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.

Two credentials meet here, and neither is the key that records. Issuing a link takes a dashboard session, a signed-in workspace user; a project API key is refused, because who gets told what is a decision a person with a role makes. 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
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
Link issuance policy.issue_timing

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.

PreviewJordan Rivera · decision made
Page state

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
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.

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

Review 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 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. 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
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

Real grants are created in the dashboard, or by the call above. The link is shown once and is the credential, so treat it like one.

Policy management

The same grant, carrying policy:manage, lets a deployer see and tune 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.

Try the parameters
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.

Cosmetics only. Which policies a team can see and change comes from the grant's scope and its policy:manage capability.

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
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.

Team management

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.

Try the parameters
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.

Cosmetics only. Who can be added and what they may hold comes from the grant.

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
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").
Test mode