Coupa supplier portal: create an invoice from a PO by workflow
Buyers on Coupa want the invoice flipped from their purchase order, in their portal. This template signs in with a vaulted login and second factor, opens the PO, creates the invoice with your number and date, saves it as a draft and verifies it exists. Whether the submit is a click or a person's decision is yours to set.
How a supplier invoices a PO in Coupa
From Coupa's public supplier documentation. Buyers configure their own requirements, so a step may ask for more than listed here.
-
Sign in with two-factor authentication
E-mail and password at the supplier portal, then a code. Coupa requires two-factor authentication for supplier accounts; an authenticator app is one of the offered methods.
-
Open the order
Orders lists the purchase orders the buyer sent, with a status and the actions per row. Opening the PO shows header and lines.
-
Create the invoice from the PO
The create-invoice action on the order prefills lines and amounts. The portal asks for the invoicing entity and remit-to address the first time, and for invoice number and date each time.
-
Attach what the buyer requires
Some buyers require the invoice PDF or a scan to be attached. Browserberg cannot hand a file into the browser today, so a buyer with that rule needs the attachment added by a person before submitting.
-
Save as draft or submit
Save as draft keeps the invoice for review; Submit sends it to the buyer and the status moves through Pending Approval to Approved or Disputed.
Who flips POs into invoices
The buyer chose the portal; the supplier pays for it in minutes per invoice.
- Billing, daily
- Each delivery becomes an invoice in the ERP and then, again, in Coupa: open the PO, create the invoice, retype number and date, attach, submit. The second entry is the one this template takes over up to the draft.
- Suppliers of several Coupa buyers
- Each buyer has its own portal instance and its own rules. The same clicks, different URLs and different mandatory fields; the template is republished per buyer.
- Disputes
- An invoice returned as Disputed has to be corrected and resubmitted. Reading the dispute reason is an extraction; the correction is a person's decision.
- What breaks
- Number and date retyped wrongly, an invoice submitted against the wrong PO, and second factors bound to one person's phone.
Facts the template relies on
| Portal | Coupa Supplier Portal at supplier.coupahost.com Coupa Supplier Portal |
|---|---|
| Second factor | Two-factor authentication is required for supplier accounts; an authenticator app is among the methods Coupa Supplier Portal |
| Invoice from PO | The create-invoice action on an order prefills the invoice from the PO lines Coupa supplier documentation |
| Draft and submit | An invoice can be saved as a draft before it is submitted; statuses after submission include Pending Approval, Approved and Disputed Coupa supplier documentation |
| Attachments | Buyer-specific; where required, a person adds the file, since file handover from Browserberg is not implemented today |
As of 2026-09-08
Template, credential with seed, and a run
The draft is verified by reading the Invoices list, not by trusting the save. The webhook body composes a parameter and a block output into one JSON object.
{
"title": "Coupa supplier portal: draft an invoice from a purchase order",
"parameters": [
{
"key": "po_number",
"description": "The purchase order to invoice, as shown under Orders",
"required": true
},
{
"key": "invoice_number",
"description": "Your invoice number",
"required": true
},
{
"key": "invoice_date",
"description": "Invoice date (YYYY-MM-DD)",
"required": true
},
{
"key": "ar_webhook",
"description": "Endpoint told when the draft exists",
"required": true
}
],
"runSequentially": true,
"sequentialKey": "cred_coupa_supplier",
"blocks": [
{
"blockType": "login",
"label": "Sign_in",
"credentialId": "cred_coupa_supplier",
"url": "https://supplier.coupahost.com/"
},
{
"blockType": "action",
"label": "Open_order",
"instruction": "Open Orders and open purchase order {{ po_number }}"
},
{
"blockType": "extraction",
"label": "Order",
"instruction": "Header and lines of purchase order {{ po_number }}: customer, currency, order total, and per line the description, quantity, unit price and line total",
"schema": {
"type": "object",
"properties": {
"customer": {
"type": "string"
},
"currency": {
"type": "string"
},
"total": {
"type": "number"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"quantity": {
"type": "number"
},
"unitPrice": {
"type": "number"
},
"lineTotal": {
"type": "number"
}
},
"required": [
"description",
"quantity"
]
}
}
},
"required": [
"lines"
]
}
},
{
"blockType": "action",
"label": "Create_draft",
"instruction": "Choose Create Invoice on the order, pick the invoicing entity and remit-to address when asked, set the invoice number to {{ invoice_number }} and the invoice date to {{ invoice_date }}, keep the prefilled lines, then choose Save as draft"
},
{
"blockType": "validation",
"label": "Draft_exists",
"criterion": "Under Invoices there is a draft {{ invoice_number }} for purchase order {{ po_number }} whose total equals the order total"
},
{
"blockType": "http_request",
"label": "Notify_ar",
"method": "POST",
"url": "{{ ar_webhook }}",
"headers": {
"content-type": "application/json"
},
"body": "{\"invoice\": \"{{ invoice_number }}\", \"order\": {{ Order_output }}}"
}
]
}
import { Browserberg } from '@browserberg/sdk';
import template from './coupa-submit-invoice.json' with { type: 'json' };
const bb = new Browserberg({
apiKey: process.env.BROWSERBERG_API_KEY,
baseUrl: 'https://browserberg.com',
});
const cred = await bb.credentials.create({
name: 'Coupa supplier portal',
url: 'https://supplier.coupahost.com/',
fields: { email: 'billing@example.de', password: process.env.COUPA_PASSWORD },
totpSeed: process.env.COUPA_TOTP_SEED, // Coupa asks for 2FA on supplier accounts
nonSecretFields: ['email'],
});
const wf = await bb.workflows.publish({
...template,
blocks: template.blocks.map((b) =>
b.blockType === 'login' ? { ...b, credentialId: cred.id } : b),
});
await using session = await bb.sessions.create();
const run = await bb.workflows.run(wf.workflowId, {
sessionId: session.id,
inputs: {
po_number: 'PO-88231', invoice_number: 'RE-2026-1187', invoice_date: '2026-09-08',
ar_webhook: 'https://ar.internal.example/hooks/coupa-draft',
},
});
console.log(run.status, run.endedBy);
Note · Drafted from Coupa's public documentation
The order view, the create-invoice action and the draft status are described as Coupa's public supplier documentation presents them on the date above. The template has not been verified against a live Coupa supplier account while writing this page, and every buyer's instance can require fields this page does not know about.
Second factor, the submit, and what is refused
Browserberg is not affiliated with Coupa Software; the supplier portal is Coupa's and this page describes operating it with the supplier account you hold.
Signing in with a seed
Coupa requires a second factor on supplier accounts. With an authenticator app, the seed goes into the vault as totpSeed and the login block produces the code at sign-in; the seed never reaches a model or a run's output. With SMS or e-mail codes there is nothing to vault, and the sign-in becomes a person's step through the live view before the run starts.
Why the template stops at a draft
Submitting an invoice is a submit effect and would run under the default policy; the gates refuse payment, destructive and bulk-export elements, and an invoice submit is none of those. The template still saves a draft rather than submitting, for two reasons that have nothing to do with the gates. First, many buyers require an attachment, and the browser cannot hand a file into the page today. Second, an invoice sent to a customer is a document your finance team may want to look at once, in the portal, before it goes. If neither applies, change the last instruction of Create_draft from Save as draft to Submit and keep the validation, adjusting it to look for the submitted status instead.
What is refused regardless
A control that voids or deletes an invoice, or a disputed-invoice action that reads as destructive, is refused under the default policy however the instruction is phrased. Under propose, such a step is handed to a person. Typing the invoice number and date is never gated.
Session
Coupa signs out after inactivity; the template does its work in one pass, and runSequentially keyed on the credential stops two runs from sharing the login.
Questions from billing
Can it submit the invoice rather than draft it?
Yes, by changing the last instruction and the validation. Submit is a submit effect and runs under the default policy. Do it once your buyer's attachment rule is known to be satisfied without a file.
What if the buyer requires the invoice PDF attached?
Then the draft is as far as the template goes today, because handing a file into the browser is not implemented. A person opens the draft, attaches and submits; the run record shows the draft was created and when.
Does it handle credit notes?
Not this template. A credit note is created from an invoice rather than from a PO, and a buyer's rules for it differ; write it as a separate workflow.
Several buyers on Coupa: one workflow?
One per buyer. The credential's release scope is the portal URL, buyers' mandatory fields differ, and a failure should name one buyer.
Is the seed ever shown to the model?
No. Codes are generated server-side from the seed at the moment the login needs one. The planner sees a placeholder; the value is typed inside the executor on the granted site only.
Read next
Draft the next invoice this way
Five browser hours, no card. Publish per buyer, run per PO, and let a person press Submit only where a person has to.