End-to-End: Invoice Reconciliation

This guide walks through building a complete invoice reconciliation workflow from scratch. By the end, you'll have a pipeline that matches invoices to payments, investigates exceptions with an AI agent, routes edge cases to human reviewers, and logs everything to an audit table: running on a daily schedule.

Prerequisites

You need a running Hyphen instance and curl. All examples use X-Org-Id: acme-corp.


Step 1: Register Org Config

Store the API keys your workflow actions will use:

bash
curl -X POST https://your-hyphen.example.com/org-config \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{ "key": "api:llm_api_key", "value": "sk-your-openai-key" }'
bash
curl -X POST https://your-hyphen.example.com/org-config \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{ "key": "api:erp_token", "value": "your-erp-api-token" }'

Config values are encrypted at rest and referenced in workflows with the orgconfig: prefix.

---

Step 2: Register HTTP Actions

Register the actions the AI agent will use as tools:

bash
# Look up a purchase order from your ERP
curl -X POST https://your-hyphen.example.com/actions \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "action_name": "lookup_purchase_order",
    "kind": "http",
    "url": "https://erp.acme.com/api/purchase-orders/{{po_number}}",
    "http_method": "GET",
    "headers": { "Authorization": "Bearer orgconfig:api:erp_token" },
    "passthrough": true
  }'
bash
# Search payment history by vendor
curl -X POST https://your-hyphen.example.com/actions \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "action_name": "check_payment_history",
    "kind": "db",
    "datasource": "orgconfig:db:finance_pg",
    "query": "SELECT * FROM payments WHERE vendor_id = $1 AND payment_date BETWEEN $2 AND $3",
    "params": ["@input.vendor_id", "@input.start_date", "@input.end_date"],
    "passthrough": true
  }'
bash
# Check for duplicate invoices
curl -X POST https://your-hyphen.example.com/actions \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "action_name": "search_duplicate_invoices",
    "kind": "db",
    "datasource": "orgconfig:db:finance_pg",
    "query": "SELECT * FROM invoices WHERE amount = $1 AND vendor_id = $2 AND invoice_date BETWEEN $3 AND $4 AND invoice_id != $5",
    "params": ["@input.amount", "@input.vendor_id", "@input.start_date", "@input.end_date", "@input.invoice_id"],
    "passthrough": true
  }'

Step 3: Create the Matcher Workflow

Start simple: just the matcher step:

bash
curl -X POST https://your-hyphen.example.com/workflows \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice_reconciliation_v1",
    "definition": {
      "actions": [
        {
          "type": "matcher",
          "properties": {
            "left": "@input.invoices",
            "right": "@input.payments",
            "matchOn": ["po_number", "vendor_id"],
            "tolerance": 50,
            "dateWindowDays": 5,
            "fuzzyThreshold": 85,
            "descriptionKey": "vendor_name",
            "outputMatched": "reconciled",
            "outputUnmatchedLeft": "unmatched_invoices",
            "outputUnmatchedRight": "unmatched_payments"
          }
        }
      ]
    }
  }'

Response:

json
{ "id": "wfl-123e4567-e89b-12d3-a456-426614174000", "name": "invoice_reconciliation_v1" }

Step 4: Execute with Sample Data

bash
curl -X POST https://your-hyphen.example.com/workflows/wfl-123e4567-e89b-12d3-a456-426614174000/execute \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "input": {
      "invoices": [
        { "invoice_id": "INV-001", "po_number": "PO-100", "vendor_id": "V-50", "vendor_name": "Acme Corp", "amount": 1000.00, "date": "2026-01-15" },
        { "invoice_id": "INV-002", "po_number": "PO-101", "vendor_id": "V-51", "vendor_name": "Beta LLC", "amount": 2500.00, "date": "2026-01-16" },
        { "invoice_id": "INV-003", "po_number": "PO-999", "vendor_id": "V-52", "vendor_name": "Gamma Inc", "amount": 750.00, "date": "2026-01-17" }
      ],
      "payments": [
        { "payment_id": "PAY-001", "po_number": "PO-100", "vendor_id": "V-50", "vendor_name": "ACME Corporation", "amount": 1000.00, "date": "2026-01-18" },
        { "payment_id": "PAY-002", "po_number": "PO-101", "vendor_id": "V-51", "vendor_name": "Beta LLC", "amount": 2450.00, "date": "2026-01-20" }
      ]
    }
  }'

Check the result:

bash
curl https://your-hyphen.example.com/runs/run-123e4567-e89b-12d3-a456-426614174000/status \
  -H "X-Org-Id: acme-corp"

You should see INV-001 matched to PAY-001 (exact match), INV-002 matched to PAY-002 ($50 difference within $50 tolerance), and INV-003 as an unmatched exception (no matching PO-999 payment).

Using uploaded files instead of inline data. For production, upload your datasets to Document Storage and reference them with the doc: prefix:

bash
# Upload invoices CSV
curl -X POST https://your-hyphen.example.com/documents -H "X-Org-Id: acme-corp" \
  -F "[email protected]" -F 'tags=["invoices"]'
# → { "document": { "id": "doc_inv123..." } }

# Execute with doc: references
curl -X POST https://your-hyphen.example.com/workflows/wfl-123e4567-e89b-12d3-a456-426614174000/execute \
  -H "X-Org-Id: acme-corp" -H "Content-Type: application/json" \
  -d '{ "input": { "invoices": "doc:doc_inv123", "payments": "doc:doc_pay456" } }'

This avoids embedding large datasets in API payloads and enables webhook-triggered automation when new files are uploaded.

---

Step 5: Add the Agent Step

Foreach runs inline-safe work, so this pattern first persists the deterministic matcher exceptions and then gives one bounded agent a fixed read tool for the current run.

Create ap_reconciliation_cases with run_id, invoice_id, vendor_name, amount, and status fields, then register the read tool:

bash
curl -X POST https://your-hyphen.example.com/actions \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "action_name": "read_ap_reconciliation_cases",
    "kind": "custom-table",
    "description": "Read AP reconciliation exceptions for the current run.",
    "properties": {
      "table": "ap_reconciliation_cases",
      "operation": "read",
      "where": { "run_id": "@__run_id" },
      "limit": 1000
    }
  }'

Update the workflow to persist and investigate exceptions:

bash
curl -X PUT https://your-hyphen.example.com/workflows/wfl-123e4567-e89b-12d3-a456-426614174000 \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice_reconciliation_v2",
    "definition": {
      "actions": [
        {
          "type": "matcher",
          "properties": {
            "left": "@input.invoices",
            "right": "@input.payments",
            "matchOn": ["po_number", "vendor_id"],
            "tolerance": 50,
            "dateWindowDays": 5,
            "fuzzyThreshold": 85,
            "descriptionKey": "vendor_name",
            "outputMatched": "reconciled",
            "outputUnmatchedLeft": "unmatched_invoices",
            "outputUnmatchedRight": "unmatched_payments"
          }
        },
        {
          "type": "loop",
          "filter": {
            "condition": { "greaterThan": [{ "length": "@unmatched_invoices" }, 0] }
          },
          "properties": {
            "mode": "foreach",
            "items_path": "@unmatched_invoices",
            "item_variable_name": "exception",
            "actions_to_execute": [
              {
                "type": "custom-table",
                "properties": {
                  "table": "ap_reconciliation_cases",
                  "operation": "upsert",
                  "key_fields": ["run_id", "invoice_id"],
                  "keys": ["run_id", "invoice_id", "vendor_name", "amount", "status"],
                  "values": ["@__run_id", "@exception.invoice_id", "@exception.vendor_name", "@exception.amount", "pending_review"]
                }
              }
            ],
            "max_concurrency": 5,
            "failure_strategy": "fail_fast",
            "collect_results": false,
            "result_key": "exception_case_writes"
          }
        },
        {
          "type": "loop",
          "filter": {
            "condition": { "greaterThan": [{ "length": "@unmatched_invoices" }, 0] }
          },
          "properties": {
            "mode": "react",
            "objective": "Read the current run's AP reconciliation cases. Investigate each invoice using the declared tools, cite the evidence returned, and recommend the next action. Do not approve a write-off.",
            "tools": [
              { "type": "action", "name": "read_ap_reconciliation_cases" },
              { "type": "action", "name": "lookup_purchase_order" },
              { "type": "action", "name": "check_payment_history" },
              { "type": "action", "name": "search_duplicate_invoices" }
            ],
            "max_iterations": 12,
            "on_stuck": { "iterations": 3, "action": "retry_with_hint", "hint": "Complete with a low-confidence recommendation for human review when evidence is insufficient." },
            "result_key": "all_investigations"
          }
        }
      ]
    }
  }'

Step 6: Add Approval for Write-Offs

Add a PbotApproval step so a human reviews the agent's findings before any write-offs:

Add this step after the agent in the actions array:

json
{
  "type": "PbotApproval",
  "filter": {
    "condition": { "greaterThan": [{ "length": "@unmatched_invoices" }, 0] }
  },
  "properties": {
    "comment": "{{unmatched_invoices.length}} exceptions investigated. Review AI findings.",
    "request_payload": {
      "reconciled_count": "@reconciled.length",
      "investigations": "@all_investigations",
      "unmatched_payments": "@unmatched_payments"
    }
  }
}

When the workflow pauses, submit the approval:

bash
curl -X POST https://your-hyphen.example.com/approvals/run-123e4567-e89b-12d3-a456-426614174000/2 \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "approved": true,
    "comment": "Reviewed. Write off INV-003 as vendor error."
  }'

Step 7: Add Audit Logging

Add a custom-table step to persist the reconciliation results:

json
{
  "type": "custom-table",
  "properties": {
    "table": "reconciliation_log",
    "operation": "write",
    "keys": ["run_id", "run_date"],
    "values": ["@__run_id", "@now"],
    "fields": {
      "total_invoices": "@input.invoices.length",
      "auto_reconciled": "@reconciled.length",
      "exceptions": "@unmatched_invoices.length",
      "status": "completed"
    }
  }
}

First, create the table:

bash
curl -X POST https://your-hyphen.example.com/custom-tables \
  -H "X-Org-Id: acme-corp" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "reconciliation_log",
    "fields": [
      { "name": "run_id", "type": "text", "required": true },
      { "name": "run_date", "type": "timestamptz", "required": true },
      { "name": "total_invoices", "type": "integer" },
      { "name": "auto_reconciled", "type": "integer" },
      { "name": "exceptions", "type": "integer" },
      { "name": "status", "type": "text", "required": true }
    ]
  }'

Step 8: Schedule for Daily Execution

Add a schedule block to the workflow definition:

json
{
  "name": "invoice_reconciliation_v3",
  "definition": {
    "schedule": {
      "every": "1d",
      "at": "02:00",
      "timezone": "America/New_York"
    },
    "actions": [ ... ]
  }
}

The workflow now runs automatically at 2 AM Eastern every day.


When you have three legs

Invoice-to-payment is two-way. When a purchase order, its receipts, and its invoices all have to agree before a payment is released, use the N-way match step instead of the matcher. It groups two to four datasets on a shared key, sums additive fields within each group, and classifies every group as full, missing_leg, or variance:

json
{
  "type": "nway_match",
  "properties": {
    "sets": [
      { "key": "purchase_order", "records": "@input.purchase_order_rows" },
      { "key": "receipt", "records": "@input.receipt_rows" },
      { "key": "invoice", "records": "@input.invoice_rows" }
    ],
    "matchOn": ["authorization_line_id"],
    "tolerances": { "line_amount": { "type": "currency", "value": 0.01 } },
    "exceptionTable": "nway_match_exceptions"
  }
}

The rest of the pipeline follows the same shape. Let exceptionTable persist the non-full groups, then bind a read-only custom-table action to those current-run rows for the agent. The shipped case CTR-11 packages that pattern in Process Studio.


What You Built

A complete reconciliation pipeline that:

  1. Matches invoices to payments on PO number, vendor ID, amount (within $50), and date (within 5 days)
  2. Investigates unmatched exceptions using an AI agent with access to your ERP and payment database
  3. Routes edge cases to a human reviewer with source records, tool observations, and the model's stated rationale
  4. Logs every run to an audit table for compliance and trend analysis
  5. Runs automatically on a daily schedule

For the full production-ready version with all steps combined, see the AP Invoice Reconciliation template.