Submit an order. Get an answer your system can act on.
Everything the web app does is available through the REST API, the MCP server and webhooks. Start with a scoped key from Settings, or ask the supplier you are ordering from to issue one.
Authenticate
Send your key in the X-API-Key header. Keys are scoped by default to orders, review, upload and reads; an admin can issue full-access keys from Settings. Every tenant-scoped path starts with /api/v1/tenants/{tenantId}. Signed-in users of the web app find their tenant id and can mint a key on the in-app developers page.
curl https://api.example.com/api/v1/tenants/{tenantId}/orders \
-H "X-API-Key: YOUR_KEY"Submit
POST /api/v1/tenants/{tenantId}/orders with a base64 document or structured lines; the response carries an envelopeId. Supported formats: PDF, CSV, XLSX, DOCX, HTML, TXT, EML, or images (PNG, JPG, GIF, WEBP). An unsupported file is refused with HTTP 415 before anything is read. Send an Idempotency-Key header so a retried submission is answered with the original envelope instead of a second one. POST /invoices takes a supplier invoice the same way.
POST /api/v1/tenants/{tenantId}/orders
X-API-Key: YOUR_KEY
Idempotency-Key: po-48821-2026-09-23
Content-Type: application/json
{ "channelId": "YOUR_CHANNEL_ID", "document": "<base64 PDF>" }
202 { "envelopeId": "734f823d-..." }Follow it
GET /api/v1/tenants/{tenantId}/review/{envelopeId}, or the get-envelope-status MCP tool, returns the envelope with its status. Terminal statuses: exported, rejected, failed, manual_handling. in_review means a person has it; stop polling and wait for the webhook. Rate limits are per tenant and per plan: Core 1,000, Growth 2,000, Scale 3,000 requests a minute (Enterprise 3,000). A separate backstop of 1,000 requests a minute applies per client IP before authentication.
- Terminal, stop polling
exportedrejectedfailedmanual_handling- A person has it, wait for the webhook
in_review
Be told
Register a URL and a secret with POST /webhooks. Each delivery is a JSON body of { event, timestamp, data, id, attempt }, signed with HMAC-SHA256 over "timestamp.body" and sent with X-OrderFlow-Signature, X-OrderFlow-Timestamp, X-OrderFlow-Event and X-OrderFlow-Delivery-Id headers. The id is stable across retries so you can dedupe. A failed delivery is retried with backoff for about nine hours and then parked for manual redelivery.
The events an ordering agent subscribes to first
envelope.in_reviewThe order is waiting for a person. Carries the routing decision (adjustedScore, action) and the order summary.envelope.auto_approvedThe order was approved without a person. This is the event you are billed on.envelope.approvedA person approved the order. Delivery to its destination follows.envelope.rejectedThe order was rejected, by a person or because it fell below the reject threshold.envelope.manual_handlingThe order was parked for manual handling, by a person or because a destination needs one.envelope.failedProcessing stopped and will not retry. errorCode says why (for example DAILY_BUDGET_EXCEEDED or UNSUPPORTED_DOCUMENT_TYPE).envelope.exportedThe approved record was written or exported. A terminal status.envelope.destination.completedOne destination landed. Fires per destination when an order goes to more than one system; carries connectorLabel, realmId and externalUrl.envelope.duplicate_blockedA document with the same PO reference was already accepted, so this one was held.order.acknowledgedThe buyer was sent an acknowledgement for the order. Carries the acknowledgement your customer system can read.
All 47 events, generated from the server
| Event | When it fires |
|---|---|
| webhook.test | Sent by the test button or POST /webhooks/:id/test. Carries no order. |
| webhook.disabled | A subscription was turned off after a day of failed deliveries. Sent to your other subscriptions; its queued deliveries resume when you turn it back on. |
| Event | When it fires |
|---|---|
| envelope.extracted | Reading finished; the order or invoice record exists. |
| envelope.in_review | The order is waiting for a person. Carries the routing decision (adjustedScore, action) and the order summary. |
| envelope.validating | Deprecated alias of envelope.in_review, fired with the same payload for one release. |
| envelope.auto_approved | The order was approved without a person. This is the event you are billed on. |
| envelope.approved | A person approved the order. Delivery to its destination follows. |
| envelope.manual_handling | The order was parked for manual handling, by a person or because a destination needs one. |
| envelope.rejected | The order was rejected, by a person or because it fell below the reject threshold. |
| envelope.failed | Processing stopped and will not retry. errorCode says why (for example DAILY_BUDGET_EXCEEDED or UNSUPPORTED_DOCUMENT_TYPE). |
| envelope.completed | Every destination for this envelope landed. Fires once per envelope. |
| envelope.destination.completed | One destination landed. Fires per destination when an order goes to more than one system; carries connectorLabel, realmId and externalUrl. |
| envelope.destination.failed | One destination failed while the others may have landed. Carries role, connectorType, code, message and nextStep. |
| envelope.destination.skipped | A destination was dropped before any write: the plan has no fan-out or per-destination delivery is off. Nothing will retry without a plan or configuration change. |
| envelope.destination.needs_reconciliation | One destination was parked for a person: its write may already have landed (code UNKNOWN_OUTCOME) or needs a manual step. Do not resubmit the order. |
| envelope.partially_delivered | At least one destination wrote and at least one did not. Do not resubmit an order that already partly landed. |
| envelope.exported | The approved record was written or exported. A terminal status. |
| envelope.export_failed | A file export failed after retries. |
| envelope.duplicate_blocked | A document with the same PO reference was already accepted, so this one was held. |
| envelope.sla_breach | An order waited in review longer than the tenant SLA allows. |
| Event | When it fires |
|---|---|
| order.acknowledged | The buyer was sent an acknowledgement for the order. Carries the acknowledgement your customer system can read. |
| order.possible_double_post | A daily read-only check found another app's document in the same QuickBooks company that looks like an order Poppelo already posted. Poppelo changes nothing; dismiss via the API. |
| Event | When it fires |
|---|---|
| autonomy.proposed | A customer reached the clean-approval streak that makes it eligible for unattended handling. A person still has to grant it. |
| autonomy.granted | A person granted a customer unattended handling. |
| autonomy.demoted | A reported problem removed a customer's unattended status, effective immediately. |
| autonomy.revoked | A person revoked a customer's unattended handling. |
| Event | When it fires |
|---|---|
| mapping_rule.proposed | The same correction was seen twice and a mapping rule was proposed. It applies only once a person activates it. |
| Event | When it fires |
|---|---|
| intake.sender_throttled | One sender exceeded the inbound message limit and its further messages were held. |
| intake.scanner_unavailable | The malware scanner was unreachable, so inbound messages were deferred, never quarantined. At most once per outage. |
| intake.mailbox_resynced | A mailbox sync cursor was too old to resume and the mailbox was resynced from now. Carries the held cursor so the gap can be judged. |
| intake.duplicate_file | An SFTP file byte-identical to one already ingested arrived again. No new envelope was created; the payload names the original envelope. |
| Event | When it fires |
|---|---|
| channel.imap_connection_failed | A dedicated IMAP mailbox failed to connect or authenticate repeatedly. The channel is marked in error and intake from it stops until the credentials are fixed. |
| channel.sftp_connection_failed | An SFTP channel could not connect. |
| channel.sftp_finalize_failed | An SFTP file was ingested but could not be moved or deleted on the server afterwards. |
| channel.oauth_connection_failed | A Gmail or Microsoft 365 mailbox grant could not be refreshed. Intake from that mailbox stops until it is reconnected. |
| Event | When it fires |
|---|---|
| connector.oauth_connection_failed | A destination connector grant could not be refreshed. Orders routed to it are held until it is reconnected. |
| connector.disconnected | A destination connection stopped working during an order write and was marked errored. Once per connection, not per order. |
| connector.warning | Orders still reach a connection, but something limits them (for example drafts created without a customer). Once per connection. |
| Event | When it fires |
|---|---|
| tenant.retention_purge_completed | The retention purge finished for this tenant. |
| tenant.export_completed | A full tenant export is ready to download. |
| tenant.export_failed | A full tenant export failed. |
| tenant.offboarding | Offboarding started for this tenant. |
| tenant.offboarded | Offboarding finished. The tenant is suspended, never hard-deleted. |
| Event | When it fires |
|---|---|
| quality.regression_detected | A trading partner's correction rate or would-auto-approve share moved outside its own baseline. |
| quality.regression_resolved | The condition behind a quality.regression_detected alert cleared. |
| Event | When it fires |
|---|---|
| format_library.hints_offered | The cross-tenant format library offered proposed reading hints for a buyer already on file. Off in every environment today. |
| Event | When it fires |
|---|---|
| golden_evaluation.completed | A check of a trading partner's approved orders finished: how many of them today's reading still gets right. |
Use it from an agent
The MCP server exposes 186 tools over stdio, one tenant per process, for Claude Desktop, Claude Code, Cursor or any MCP client. It is a private package (@orderflow/mcp-server) built from a checkout of the repository at packages/mcp-server; the in-app developers page has the exact configuration with your tenant id filled in.
The five tools an ordering agent needs
submit-ordersubmit-orders-bulkget-envelope-statusget-orderexplain-order-routing
All 186 tools, generated from the package
Orders (11)
submit-ordersubmit-orders-bulkget-orderexplain-order-routinglist-ordersget-envelope-statussubmit-manual-orderreprocess-envelopedismiss-envelopereport-problemget-order-timeline
Invoices (5)
submit-invoiceget-invoicelist-invoicesapprove-invoicereject-invoice
Delivery and reconciliation (5)
get-reconciliationget-export-fileretry-exportlist-order-destinationsretry-destination
Tenant and account (18)
update-tenant-settingsget-tenantrequest-tenant-exportget-tenant-export-statusget-rate-limit-statusget-onboarding-statusget-entitlementsget-audit-logget-billing-subscriptionget-billing-usagelist-privacy-requestsget-privacy-request-exportlist-format-library-partnersdeactivate-tenantreactivate-tenantlist-billing-planscreate-billing-checkoutchange-billing-plan
Intake channels (4)
list-channelsconfigure-sftp-channeltest-sftp-connectionget-channel-metrics
Metrics and quality (18)
get-metricsget-trust-metricsget-accuracy-metricsget-roi-statementlist-partner-accuracylist-quality-alertsacknowledge-quality-alertrun-sla-checkrun-diagnosticget-diagnosticget-customer-metricsget-queue-healthget-metrics-timeseriesstart-partner-checkget-partner-checkget-dashboardget-daily-summaryupdate-morning-summary-settings
Team (13)
list-usersinvite-userlist-invitationsrevoke-invitationresend-invitationchange-user-rolelist-user-passkeysreset-user-mfadeactivate-userreactivate-userlist-my-sessionslist-my-passkeysrevoke-my-session
API keys (5)
manage-api-keyscreate-api-keyrevoke-api-keyrotate-api-keyget-api-key-usage
Review (16)
list-attention-itemslist-review-queueget-review-detailget-review-document-textapprove-ordermatch-order-customerreject-orderskip-reviewundo-reviewmark-manual-handlingpreview-destination-routingget-posting-previewclaim-reviewrelease-reviewsearch-review-customerssearch-review-skus
Destination connectors (23)
reactivate-connectorget-integration-statustest-connectionconfigure-netsuite-connectortest-netsuite-connectionget-netsuite-connectorconfigure-custom-rest-connectorget-custom-rest-connectorsend-test-order-custom-restget-shopify-connect-urlget-quickbooks-connect-urlset-active-destinationset-active-destination-instancedisconnect-connector-instancedisconnect-connectorconfigure-connector-instanceset-posting-decisionsuse-existing-quickbooks-customerget-quickbooks-posting-optionslist-shopify-appsregister-shopify-appupdate-shopify-appdelete-shopify-app
Destination routing (12)
list-destination-rulescreate-destination-ruleupdate-destination-ruledelete-destination-rulelist-entitiescreate-entityupdate-entitydelete-entityget-routing-rule-optionsreorder-destination-rulesactivate-destination-ruledismiss-destination-rule
Rules and profiles (9)
list-mapping-rulesimport-cross-referencescreate-mapping-rulecreate-reading-hintapprove-mapping-rulesuspend-mapping-rulelist-customer-profilesdelete-profile-mappingdelete-customer-profile
Webhooks (9)
list-webhookscreate-webhookupdate-webhookdelete-webhooklist-pending-webhook-deliveriesredeliver-webhooklist-webhook-deliveriestest-webhookrotate-webhook-secret
Reference data (8)
list-reference-customerslist-reference-productsget-reference-sync-statusget-reference-import-statustrigger-reference-syncretire-csv-reference-dataimport-reference-customersimport-reference-products
Single sign-on (18)
get-sso-settingsupdate-sso-settingsget-sso-metadatalist-sso-domainsadd-sso-domainverify-sso-domaindelete-sso-domainlist-sso-sessionsrevoke-sso-sessionrevoke-all-sso-sessionsget-scim-directorycreate-scim-directoryrotate-scim-tokendelete-scim-directorylist-sso-connectionscreate-sso-saml-connectioncreate-sso-oidc-connectiondelete-sso-connection
Per-customer autonomy (4)
list-autonomy-customersget-autonomy-customergrant-customer-autonomyrevoke-customer-autonomy
Order acknowledgements (5)
acknowledge-orderlist-order-acknowledgementsget-order-acknowledgement-settingsupdate-order-acknowledgement-settingsset-customer-acknowledgement
Double-post findings (3)
list-double-post-findingslist-order-double-post-findingsdismiss-double-post-finding
Errors
Every error is { error: { code, message, details, correlationId } }. Quote the correlationId to support. These are the codes an ordering agent must handle on purpose:
| Code | Where | What to do |
|---|---|---|
| MULTI_PO_MERGED | 409 on every approve path | Several purchase orders were merged from one email. No one can approve it; reject it and ask the sender for one PO per email. |
| DESTINATION_CHOICE_REQUIRED | 409 on approve | A role the order writes to has several connected instances and no rule names one. Resend with destinationChoices set to one of error.details.roles[].instances[].id. |
| APPROVAL_NEEDS_ACKNOWLEDGEMENT | 409 on approve | The order has an unmatched line, an unknown customer or a PO number already received. Resend with the acknowledge flags error.details.required names. |
| DAILY_BUDGET_EXCEEDED | errorCode on envelope.failed | The daily inference budget is spent. The document failed and will not retry today. |
| UNSUPPORTED_DOCUMENT_TYPE | HTTP 415, or errorCode on envelope.failed for SFTP | The file is a format the pipeline cannot read. Refused at intake, before extraction. |
For retailers and marketplaces (beta target)
If your suppliers use Poppelo, your procurement agent can submit to their tenant with a key they issue, receive the order.acknowledged event with the acknowledgement they send, and read the routing explanation for any order that was held. The supplier hands you one thing: an integration-scoped key. Contact us to join the beta.
Questions from agent developers
What happens if I submit the same document twice?
Send an Idempotency-Key header with every submission. A retried request with the same key returns the original envelope instead of creating a second one, so a timed-out call is always safe to retry.
What if a webhook delivery fails?
A failed delivery is retried with backoff for about nine hours and then parked for manual redelivery. The delivery id stays the same across every attempt, so your handler can dedupe on it.
Do I need to poll for order status?
No. Register a webhook and read the terminal and needs-person statuses off the payload. Polling get-envelope-status still works, but a webhook reaches you the moment the status changes instead of after your next poll.