>_ Analyst Engineering

Arazzo for Analysts: Multi-Step API Workflows as Testable YAML

Written by Ahmed at Analyst Engineering, a Senior Technical Business Analyst with 10+ years in banking and payments delivery.

Cover for Arazzo for analysts, showing a payment workflow of create, confirm, wait for settlement, and refund, written as Arazzo steps with success criteria.

Key takeaways

  • Arazzo is the OpenAPI Initiative's specification for describing multi-step API workflows: which operations run in which order, what data flows from one step to the next, and what counts as success at each step.
  • An Arazzo document has four parts that matter to an analyst: sourceDescriptions point to the OpenAPI files, workflows hold ordered steps, successCriteria say what a correct response looks like, and outputs pass values such as a payment id to later steps through runtime expressions like $steps.createIntent.outputs.intentId.
  • Arazzo 1.1.0, dated 17 May 2026 in its revision history, adds AsyncAPI steps that send or receive messages, a correlationId and timeout per step, step-level dependsOn, Selector Objects, and $self; tools such as Redocly Respect still document support for Arazzo 1.0.1 only.
  • The sequence that lives in a Confluence page and a Bruno collection becomes one machine-readable file that a linter can check, a runner can execute against a sandbox, and a reviewer can diff in a pull request.
  • Polling an asynchronous status is expressed with an onFailure action of type retry, with retryAfter, retryLimit, and criteria that match the in-flight status, so the wait is part of the specification rather than hidden in a script.

Arazzo is the OpenAPI Initiative’s specification for describing multi-step API workflows in YAML: which operations run in which order, what data passes from one step to the next, and what a successful response looks like at each step. For an analyst, it turns the payment sequence that lives in a Confluence table into a file a linter can check, a runner can execute against a sandbox, and a reviewer can diff. The current version is 1.1.0, dated 17 May 2026.

An OpenAPI contract tells you every operation an API offers and nothing about the order you must call them in. That order is the integration: create the payment before confirming it, wait until it settles before refunding it, carry the id across. Today it lives in a sequence diagram, a paragraph of prose, and the post-response scripts of a Bruno collection. This article is part of the advanced track of APIs for Analysts.

APIs for Analysts, advanced track. Builds on chaining API requests with JavaScript and how to document an API. Full learning path: APIs for Analysts.

What is Arazzo, and why should an analyst care?

Arazzo describes sequences of API calls and the dependencies between them. It was released as 1.0.0 in 2024, patched as 1.0.1 in January 2025, and extended as 1.1.0 in May 2026. The specification text is at spec.openapis.org/arazzo.

The analyst case is simple. In every payment integration I have worked on, the most important artifact was the end-to-end flow, and it was the one artifact nobody could execute. The sequence diagram drifted from the collection, the collection drifted from the API, and the Confluence page drifted from both. Arazzo collapses three of those into one file that is precise enough to run.

Where the sequence lives todayWhat it cannot doWhat Arazzo adds
A sequence diagramBe executed or validatedThe same order of calls, as a runnable workflow
A Confluence table of stepsShow what counts as success at each stepsuccessCriteria per step
A Bruno or Postman collectionBe read without opening scriptsData flow declared as outputs and runtime expressions
Prose in the API docsFail a build when it goes staleA linter and a runner in CI

How is an Arazzo document structured?

Five fields carry almost all the meaning.

FieldWhat it holdsAnalyst reading
arazzoThe specification version, such as 1.0.1 or 1.1.0Which tools can read this file
sourceDescriptionsNamed links to OpenAPI (and, from 1.1, AsyncAPI or other Arazzo) documentsWhich contracts this flow depends on
workflowsA list of workflows, each with a workflowId, inputs (a JSON Schema), steps, and outputsOne business scenario each
stepsAn ordered list; each step references an operationId, an operationPath, or another workflowIdOne call, its inputs, and its expected result
componentsReusable parameters, successActions, failureActions, and inputsShared rules, such as one retry policy

Inside a step, four fields do the work:

  • parameters and requestBody: what to send. Values can be literals or runtime expressions.
  • successCriteria: a list of conditions that must all pass, such as $statusCode == 200. Conditions can be simple, regex, jsonpath, or xpath.
  • outputs: named values extracted from the response, such as intentId: $response.body#/id.
  • onSuccess and onFailure: what happens next. A success action is end or goto; a failure action is end, goto, or retry with retryAfter (seconds) and retryLimit. With no onFailure, the default is to stop and return.

Runtime expressions are the glue. The ones you will use daily:

ExpressionMeaning
$inputs.amountA workflow input
$statusCodeThe HTTP status of this step’s response
$response.body#/statusA field in the response body, by JSON Pointer
$response.header.Request-IdA response header
$steps.createIntent.outputs.intentIdAn output of an earlier step
$sourceDescriptions.stripe.PostPaymentIntentsAn operation in a named source description
Bearer {$inputs.stripeKey}An expression embedded in a string, in curly braces

String literals in conditions use single quotes, and the specification requires string comparisons to be case-insensitive, which matters when you assert on status values.

What does a payment workflow look like in Arazzo?

The worked example uses Stripe’s public API in a sandbox, so you can run it. It is the same flow as the hand-scripted chain in chaining API requests with JavaScript: create a PaymentIntent, confirm it with a test card, wait until the status is final, refund it, and wait until the refund is final. The operationIds come from Stripe’s published OpenAPI file: PostPaymentIntents, PostPaymentIntentsIntentConfirm, GetPaymentIntentsIntent, PostRefunds, and GetRefundsRefund. Stripe’s request bodies are form-encoded, which is why the contentType is application/x-www-form-urlencoded.

arazzo: 1.0.1
info:
  title: Card payment to refund (Stripe sandbox)
  summary: Create, confirm, wait for a final status, refund, wait for the refund.
  version: 1.0.0
sourceDescriptions:
  - name: stripe
    url: https://raw.githubusercontent.com/stripe/openapi/master/latest/openapi.spec3.yaml
    type: openapi

workflows:
  - workflowId: payAndRefund
    summary: Happy path from a card payment to a completed refund
    inputs:
      type: object
      required: [stripeKey, amount, currency, orderRef]
      properties:
        stripeKey: { type: string, description: "Sandbox secret key, sk_test_..." }
        amount: { type: integer, description: "Minor units, 1250 = 12.50" }
        currency: { type: string, examples: [eur] }
        orderRef: { type: string, description: "Merchant reference, reused in idempotency keys" }
        paymentMethod: { type: string, default: pm_card_visa }
    parameters:
      - name: Authorization
        in: header
        value: Bearer {$inputs.stripeKey}
    steps:
      - stepId: createIntent
        description: Create the PaymentIntent. The idempotency key makes a retried create safe.
        operationId: $sourceDescriptions.stripe.PostPaymentIntents
        parameters:
          - name: Idempotency-Key
            in: header
            value: pi-{$inputs.orderRef}
        requestBody:
          contentType: application/x-www-form-urlencoded
          payload:
            amount: $inputs.amount
            currency: $inputs.currency
            "automatic_payment_methods[enabled]": true
            "automatic_payment_methods[allow_redirects]": never
        successCriteria:
          - condition: $statusCode == 200
          - condition: $response.body#/status == 'requires_payment_method'
        outputs:
          intentId: $response.body#/id

      - stepId: confirmIntent
        operationId: $sourceDescriptions.stripe.PostPaymentIntentsIntentConfirm
        parameters:
          - name: intent
            in: path
            value: $steps.createIntent.outputs.intentId
        requestBody:
          contentType: application/x-www-form-urlencoded
          payload:
            payment_method: $inputs.paymentMethod
        successCriteria:
          - condition: $statusCode == 200

      - stepId: waitForPayment
        description: Poll until the PaymentIntent reaches succeeded.
        operationId: $sourceDescriptions.stripe.GetPaymentIntentsIntent
        parameters:
          - name: intent
            in: path
            value: $steps.createIntent.outputs.intentId
        successCriteria:
          - condition: $statusCode == 200
          - condition: $response.body#/status == 'succeeded'
        onFailure:
          - name: stillProcessing
            type: retry
            retryAfter: 2
            retryLimit: 15
            criteria:
              - condition: $response.body#/status == 'processing'
        outputs:
          paymentStatus: $response.body#/status

      - stepId: createRefund
        operationId: $sourceDescriptions.stripe.PostRefunds
        parameters:
          - name: Idempotency-Key
            in: header
            value: re-{$inputs.orderRef}
        requestBody:
          contentType: application/x-www-form-urlencoded
          payload:
            payment_intent: $steps.createIntent.outputs.intentId
        successCriteria:
          - condition: $statusCode == 200
        outputs:
          refundId: $response.body#/id

      - stepId: waitForRefund
        description: Refund status is not guaranteed final at response time.
        operationId: $sourceDescriptions.stripe.GetRefundsRefund
        parameters:
          - name: refund
            in: path
            value: $steps.createRefund.outputs.refundId
        successCriteria:
          - condition: $statusCode == 200
          - condition: $response.body#/status == 'succeeded'
        onFailure:
          - name: refundPending
            type: retry
            retryAfter: 2
            retryLimit: 15
            criteria:
              - condition: $response.body#/status == 'pending'
        outputs:
          refundStatus: $response.body#/status

    outputs:
      paymentIntentId: $steps.createIntent.outputs.intentId
      refundId: $steps.createRefund.outputs.refundId
      refundStatus: $steps.waitForRefund.outputs.refundStatus

Read it as a reviewer and four analyst decisions are visible that a collection usually hides.

  1. The polling rule is in the contract. waitForPayment succeeds only on succeeded, retries every 2 seconds while the status is processing, and gives up after 15 retries. Any other status (requires_action, canceled) fails the step immediately. In a Bruno collection, that rule is inside a JavaScript loop nobody reviews.
  2. Idempotency is explicit. Both writes carry an Idempotency-Key derived from the merchant’s orderRef, so a rerun after a timeout does not create a second payment or a second refund. That is the property you test in idempotency testing, now declared.
  3. The data flow is declared. intentId is produced once by createIntent and consumed by three later steps. The specification tells runners to treat $steps.createIntent.outputs.intentId as a dependency, and recommends an error when a step references an output of a step that comes later.
  4. The expected state at creation is asserted. Checking requires_payment_method after create catches the day someone adds confirm=true to the create call and the flow silently changes shape.

Why is the file pinned to Arazzo 1.0.1?

Because the tools that run it today document 1.0.x. Redocly’s respect command documentation states it supports Arazzo 1.0.1 descriptions only, Spectral’s Arazzo ruleset is written against 1.0, and Speakeasy’s arazzo validate command links to 1.0.1. The workflow above uses no 1.1-only field, so pinning to 1.0.1 costs nothing.

How do you write the negative path?

A second workflow in the same file covers the decline. The trick is that successCriteria describe what the test expects, so for a negative test the expected result is the error.

  - workflowId: declinedCard
    summary: A declined card is rejected with card_declined
    inputs:
      type: object
      required: [stripeKey, orderRef]
      properties:
        stripeKey: { type: string }
        orderRef: { type: string }
    parameters:
      - name: Authorization
        in: header
        value: Bearer {$inputs.stripeKey}
    steps:
      - stepId: createAndConfirm
        operationId: $sourceDescriptions.stripe.PostPaymentIntents
        requestBody:
          contentType: application/x-www-form-urlencoded
          payload:
            amount: 1250
            currency: eur
            confirm: true
            payment_method: pm_card_visa_chargeDeclined
            "automatic_payment_methods[enabled]": true
            "automatic_payment_methods[allow_redirects]": never
        successCriteria:
          - condition: $statusCode == 402
          - condition: $response.body#/error/code == 'card_declined'

One workflow per scenario, and the file reads like the scenario list in your test plan, which is exactly what it is. Deriving that scenario list is covered in how to write API test cases.

How does Arazzo relate to request chaining in Bruno and Postman?

It is the same idea moved from imperative scripts into declarative YAML.

Bruno or PostmanArazzo
Request order in the collectionOrder of steps
Environment variablesWorkflow inputs
bru.setVar("intentId", res.body.id) or pm.collectionVariables.set(...)outputs: { intentId: $response.body#/id }
{{intentId}} in the next request$steps.createIntent.outputs.intentId
assert block or expect(...) in testssuccessCriteria
A polling loop in a post-response scriptonFailure with type: retry, retryAfter, retryLimit
bru.runner.setNextRequest() or pm.execution.setNextRequest()onSuccess or onFailure with type: goto

What you lose is flexibility: Arazzo cannot compute an HMAC signature or generate a UUID, which is why the example derives idempotency keys from an input. What you gain is a sequence that any compliant tool can read without executing your JavaScript. My practical split: keep the Bruno collection for exploratory and regression work, and write the Arazzo file for the five or six business-critical flows that must stay documented and tested together. Generating either one with an assistant follows the same rules as in AI-built API collections and scripts: give it the contract, demand real assertions, and run the result.

The habit of writing every flow with its success conditions and failure branches, so consumers and testers read the same thing, is what API Documentation from Scratch teaches, and API Testing and QA Mastery for BAs covers the scenario design behind the workflows.

Which tools can lint and run Arazzo today?

Current as of October 2026, from each project’s own documentation:

ToolWhat it does with ArazzoCommand
Spectral (Stoplight)Lints with the built-in spectral:arazzo ruleset: arazzo-document-schema, arazzo-workflowId-unique, arazzo-workflow-stepId-unique, arazzo-workflow-output-validation, and morespectral lint payment-refund.arazzo.yaml
Redocly CLI, respectExecutes workflows as tests against a live API; documents support for Arazzo 1.0.1 onlynpx @redocly/cli@latest respect payment-refund.arazzo.yaml
Speakeasy openapi CLIValidates structure, step references, expressions, and actionsopenapi arazzo validate ./payment-refund.arazzo.yaml
Speakeasy SDK testingGenerates SDK contract tests from .speakeasy/tests.arazzo.yamlConfigured in the SDK repo
Jentic Arazzo RunnerPython execution engine for Arazzo workflowsuvx arazzo-runner execute-workflow ...

A minimal local loop:

# Lint: structure, unique ids, valid output expressions
echo 'extends: ["spectral:arazzo"]' > .spectral.yaml
npx @stoplight/spectral-cli lint payment-refund.arazzo.yaml

# Run against the Stripe sandbox; the key comes from your shell, not the file
npx @redocly/cli@latest respect payment-refund.arazzo.yaml \
  --workflow payAndRefund \
  --input stripeKey=$STRIPE_SECRET_KEY \
  --input amount=1250 --input currency=eur --input orderRef=ord-20261009-001 \
  --verbose

Put the same two commands in the pipeline and the flow becomes a release control, the same move described in API tests in CI.

What did Arazzo 1.1 add?

Arazzo 1.1.0 is dated 17 May 2026 in the specification’s revision history, and the OpenAPI Initiative announced it on 19 May 2026. The additions an analyst should know:

  • AsyncAPI steps. A source description can now be of type asyncapi. A step can send or receive a message (the action field), reference a channel with channelPath, match replies with correlationId, and wait up to a timeout in milliseconds. Payload values are read with $message.payload. A flow can now say “POST the payment, then wait for the payment.settled event”, which is how most real payment platforms work. The event side is covered in AsyncAPI for analysts.
  • dependsOn on steps, to declare that a step must wait for asynchronous work to finish.
  • Selector Objects with jsonpath, xpath, or jsonpointer, for precise extraction.
  • querystring parameters, aligned with OpenAPI 3.2. The OpenAPI side of that change is in OpenAPI 3.1 and 3.2 for analysts.
  • $self, a root URI for unambiguous reference resolution, plus a complete ABNF grammar for runtime expressions and explicit truthy and falsy rules for conditions.

Watch the tool tables before adopting 1.1 features: as of October 2026, the runner documentation I could verify still names 1.0.1.

Can AI agents use Arazzo workflows?

This is where a lot of the current interest comes from, so it is worth being precise. An agent given only an OpenAPI file sees two hundred operations and has to guess the order, the data dependencies, and when a status is final. An Arazzo workflow gives it the order, the inputs, the success conditions, and the retry rules, written down by someone who knows the domain. Jentic, for example, describes its open-source Arazzo Runner as an engine that “powers agents and automation by orchestrating multi-step API flows”. The Arazzo 1.1 announcement lists actor-in-the-loop support (human or agent) as a roadmap item, not a released feature.

For an analyst, the practical point is the same either way: the workflow you write for a human tester is the workflow an agent will execute, so the success criteria and failure branches need the same rigour. AI agents for analysts covers how to specify what an agent may and may not do around those calls.

The takeaway

Arazzo describes the order of API calls, the data passed between them, and the expected result at each step, in a YAML file that references operations in your OpenAPI contracts. For an analyst, that turns the end-to-end flow from a diagram nobody executes into an artifact you can lint with Spectral, run with Redocly Respect, and review in a pull request. Write one workflow per business scenario, put the polling and idempotency rules in the file rather than in scripts, pin the version to what your runner supports, and keep collections for the exploratory work Arazzo is not built for.

Ahmed is a Senior Technical Business Analyst with 10+ years in banking and payments. He builds practical guides and tools for analysts at The Tech BA Toolkit.

Tags: Arazzo, OpenAPI, API Workflows, API Testing, Systems Analysis, Payments

About the author

Analyst Engineering is written by Ahmed, a Senior Technical Business Analyst with 10+ years of banking and payments delivery experience: ISO 20022 and SWIFT messaging, payments API integration, Kafka event validation, and production support. Every article comes from real delivery work, and each one is reviewed and updated as tools and standards change.

Go deeper on this

Not ready to buy? The free downloads are a no-cost place to start, and every article here stays free.

Free account

Practice on the Labs, keep your progress

A free account, no password: an email link signs you in. It saves your steps and self-assessments on the Labs, shows your missions on a dashboard, unlocks the solutions, and, if you tick the box, sends you new missions and articles when they ship.

Your email is used to sign you in. Nothing else, unless you ask. Privacy.