This integration playbook covers the cross-system Procure-to-Pay flow where procurement operations (requisition, sourcing, PO creation) happen in one system and financial operations (invoice matching, payment processing) happen in another — typically the ERP. The most common architecture pairs a cloud procurement platform (Coupa, SAP Ariba, Jaggaer) with a financial ERP (SAP S/4HANA, Oracle ERP Cloud, NetSuite, D365 Finance).
| System | Role | API Surface | Direction |
|---|---|---|---|
| Procurement Platform (Coupa, SAP Ariba, Jaggaer) | Source of truth for requisitions, POs, catalogs | REST API, cXML/OCI | Outbound |
| SAP S/4HANA | ERP — financial master, AP, payments | OData v4 (A_PurchaseOrder), BAPI, IDoc | Inbound |
| Oracle ERP Cloud | ERP — financial master, AP, payments | REST, FBDI, SOAP | Inbound |
| NetSuite | ERP — financial master, AP, payments | REST, SuiteTalk SOAP | Inbound |
| Dynamics 365 Finance | ERP — financial master, AP, payments | OData v4, Data Entities | Inbound |
| iPaaS (MuleSoft, Boomi, Workato, Celigo) | Middleware — orchestration, transformation | N/A | Orchestrator |
| API Surface | System | Protocol | Best For | Max Records/Request | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|---|
| OData v4 (A_PurchaseOrder) | SAP S/4HANA Cloud | HTTPS/JSON | PO CRUD, real-time sync | 1,000/page | Fair use / ICM | Yes | No |
| BAPI_PO_CREATE1 / RFC | SAP S/4HANA On-Prem | RFC/ABAP | PO creation from external | 1 PO (multi-line) | Work process limit | Yes | No |
| IDoc ORDERS05 | SAP S/4HANA | ALE/EDI | Batch PO transmission | 1 PO/IDoc, batch queued | ALE queue | No | Yes |
| REST (Procurement) | Oracle ERP Cloud | HTTPS/JSON | PO, requisition, receipt | 500/page (default) | Throttled/tenant | Yes | No |
| FBDI | Oracle ERP Cloud | CSV/REST upload | Bulk PO/invoice import | 250 MB/file | Job queue | No | Yes |
| SuiteTalk SOAP | NetSuite | HTTPS/XML | PO, vendor bill | 1,000/search | 10 concurrent | Yes | No |
| REST API | NetSuite | HTTPS/JSON | Real-time PO/invoice | 1,000/page | Token-based | Yes | No |
| OData v4 (Data Entities) | D365 Finance | HTTPS/JSON | PO, product receipt | 10,000/page | 6,000 req/5min | Yes | Yes (DMF) |
| Limit Type | Value | System | Notes |
|---|---|---|---|
| Max PO line items per API call | 999 | SAP BAPI_PO_CREATE1 | Split POs with >999 lines |
| Max page size (OData) | 1,000 | SAP S/4HANA Cloud | Use $skip/$top for pagination |
| Max page size (REST) | 500 (default), 2,000 (max) | Oracle ERP Cloud | Set via limit parameter |
| Max file size (FBDI) | 250 MB | Oracle ERP Cloud | Split larger files across jobs |
| Max search page size | 1,000 | NetSuite SuiteTalk | Use searchMoreWithId for pagination |
| Max records per page | 10,000 | D365 Finance | Use $skiptoken for pagination |
| Max payload size | 50 MB | D365 Finance | For batch Data Entity operations |
| Limit Type | Value | System | Notes |
|---|---|---|---|
| OData API calls | Fair use / ICM-managed | SAP S/4HANA Cloud | No hard cap; ICM throttles at high load |
| RFC connections | Dialog work process pool | SAP S/4HANA On-Prem | Typically 20-80 dialog processes |
| REST API requests | Throttled per tenant | Oracle ERP Cloud | 1,000-5,000/min typical |
| Concurrent FBDI jobs | 4-8 per module | Oracle ERP Cloud | Shared across Procurement + AP |
| Concurrent SuiteTalk | 10 requests | NetSuite | Per-account concurrency |
| REST API governance | 10 units/request | NetSuite | SuiteCloud governance units |
| OData requests | 6,000/5min per user | D365 Finance | Service-to-service uses shared pool |
| DMF recurring jobs | 50 concurrent | D365 Finance | Data Management Framework batch |
| Flow | System | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|---|
| OAuth 2.0 SAML Bearer | SAP S/4HANA Cloud | Server-to-server OData | Session-based | Via new SAML assertion | Requires Communication Arrangement |
| X.509 Certificate + RFC | SAP S/4HANA On-Prem | Middleware BAPI calls | Session | N/A | SNC recommended |
| OAuth 2.0 JWT | Oracle ERP Cloud | Server-to-server REST/SOAP | 1 hour | Yes | IDCS/IAM app registration |
| Token-Based Auth (TBA) | NetSuite | All SuiteTalk/REST calls | Until revoked | N/A | Consumer key + token key/secret |
| OAuth 2.0 Client Credentials | D365 Finance | Server-to-server OData | 1 hour | Yes | Azure AD app registration |
START — User needs to integrate P2P across procurement + ERP
├── What's the primary P2P integration scope?
│ ├── Full P2P (Req → PO → Receipt → Invoice → Payment)
│ │ ├── Data volume < 500 POs/day? → Real-time API sync with middleware
│ │ └── > 500 POs/day? → Event-driven + batch hybrid
│ ├── PO sync only (procurement → ERP)
│ │ ├── SAP target → OData v4 A_PurchaseOrder or BAPI_PO_CREATE1
│ │ ├── Oracle target → REST /fscmRestApi/resources/purchaseOrders
│ │ ├── NetSuite target → REST or SuiteTalk PurchaseOrder
│ │ └── D365 target → OData PurchaseOrderHeadersV2
│ ├── Invoice matching only
│ │ ├── 2-way match (PO + Invoice) → Simpler, no receipt dependency
│ │ └── 3-way match (PO + Receipt + Invoice) → Requires receipt sync first
│ └── Payment only → Use bank integration, not P2P
├── Which direction?
│ ├── Procurement → ERP (PO push, receipt pull) → Most common
│ ├── ERP → Procurement (catalog sync, budget push) → Supplementary
│ └── Bidirectional → Design conflict resolution for shared objects
├── Middleware or direct?
│ ├── Direct API-to-API → Only for <100 POs/day
│ ├── iPaaS middleware → Recommended for all production P2P
│ └── File-based (FBDI, IDoc, CSV) → Legacy or ultra-high-volume batch
└── Error tolerance?
├── Zero-loss → Idempotent PO creation + dead letter queue + reconciliation
└── Best-effort → Retry with backoff (not recommended for financial data)
| Step | Source System | Action | Target System | Data Objects | Failure Handling |
|---|---|---|---|---|---|
| 1 | Procurement | Purchase requisition approved | Procurement | Requisition → PO candidate | Internal workflow |
| 2 | Procurement | PO created and approved | ERP (via middleware) | Purchase Order (header + lines) | Retry 3x, then DLQ |
| 3 | Procurement | PO transmitted to supplier | Supplier (EDI/cXML) | PO document | EDI ACK; resend on NACK |
| 4 | Supplier | Order confirmation / ASN | ERP or Procurement | PO acknowledgment | Log; manual follow-up |
| 5 | Warehouse/ERP | Goods receipt posted | ERP | Goods Receipt (against PO) | Must link to PO number |
| 6 | Supplier | Invoice submitted | AP system or ERP | Vendor Invoice | OCR/IDR capture; matching queue |
| 7 | ERP (AP module) | 3-way match executed | ERP | PO + Receipt + Invoice | Auto-approve or exception queue |
| 8 | ERP (AP module) | Payment run executed | Bank / Payment gateway | Payment batch | Bank file reconciliation |
Before any transactional P2P data flows, ensure supplier/vendor records exist in both systems with matching cross-reference IDs. [src5]
def sync_supplier_to_erp(supplier, erp_config):
existing = requests.get(
f"{erp_config['base_url']}/vendors",
params={"external_id": supplier["id"]},
headers={"Authorization": f"Bearer {erp_config['token']}"}
)
if existing.json().get("count", 0) > 0:
vendor_id = existing.json()["items"][0]["id"]
response = requests.patch(
f"{erp_config['base_url']}/vendors/{vendor_id}",
json={"name": supplier["name"], "payment_terms": supplier["payment_terms"]}
)
else:
response = requests.post(
f"{erp_config['base_url']}/vendors",
json={"name": supplier["name"], "external_id": supplier["id"]}
)
return response.json()
Verify: Query ERP vendor list filtered by external_id → expected: vendor record with matching cross-reference.
When a PO is approved, create the corresponding PO in the ERP via OData/REST. This is the highest-volume integration point. [src3, src4]
def push_po_to_sap(po, sap_config):
# Idempotency check
check = requests.get(
f"{sap_config['base_url']}/.../PurchaseOrder",
params={"$filter": f"YY1_ExtPORef eq '{po['po_number']}'"}
)
if check.json().get("value", []):
return {"status": "already_exists"}
# Create PO
sap_po = {
"CompanyCode": po["company_code"],
"PurchaseOrderType": "NB",
"Supplier": po["vendor_erp_id"],
"_PurchaseOrderItem": [...]
}
return requests.post(..., json=sap_po).json()
Verify: GET /PurchaseOrder?$filter=YY1_ExtPORef eq '{po_number}' → PO with status 'Created'.
Post goods receipt against the PO in the ERP. This is the prerequisite for 3-way matching. [src1, src2]
def post_goods_receipt_sap(receipt, sap_config):
gr_payload = {
"GoodsMovementType": "101",
"_MaterialDocumentItem": [{
"PurchaseOrder": receipt["erp_po_number"],
"QuantityInEntryUnit": str(line["received_quantity"])
} for line in receipt["lines"]]
}
return requests.post(..., json=gr_payload).json()
Verify: Material document created with reference to PO → movement type 101.
Capture invoice, match against PO and receipt, route for approval or exception handling. [src7]
def process_invoice_with_matching(invoice, erp_config):
po = get_po(invoice["po_number"])
receipts = get_receipts(invoice["po_number"])
for inv_line in invoice["lines"]:
price_variance = abs(inv_line["unit_price"] - po_line["unit_price"]) / po_line["unit_price"]
qty_variance = abs(inv_line["quantity"] - gr_qty) / gr_qty
# Auto-approve if within tolerance; exception queue otherwise
return {"status": "auto_approved" if all_matched else "exception"}
Verify: Invoice status → 'auto_approved' or 'exception' with variance percentages.
After invoices are approved, execute the payment run to generate payment files. [src1]
# Oracle ERP Cloud — trigger payment process request
curl -X POST "${ORACLE_BASE_URL}/fscmRestApi/.../paymentProcessRequests" \
-H "Authorization: Bearer ${TOKEN}" \
-d '{"PaymentProcessProfile":"Standard","PaymentDate":"2026-03-15"}'
Verify: Payment process request status → 'COMPLETED'.
# Input: Webhook event from procurement system
# Output: Corresponding record created in ERP with error handling
class P2PIntegrationHandler:
def __init__(self, procurement_config, erp_config, tolerance_config):
self.procurement = procurement_config
self.erp = erp_config
self.tolerances = tolerance_config
def handle_event(self, event_type, payload):
handlers = {
"po_approved": self._handle_po_approved,
"goods_received": self._handle_goods_received,
"invoice_received": self._handle_invoice_received,
}
return self._retry_with_backoff(handlers[event_type], payload)
def _retry_with_backoff(self, func, payload, max_retries=3):
for attempt in range(max_retries):
try:
return func(payload)
except requests.exceptions.HTTPError as e:
if e.response.status_code in (429, 500, 502, 503):
time.sleep(2 ** attempt)
else:
raise
return {"status": "dead_letter", "payload": payload}
// Input: PO object from procurement system
// Output: SAP PO number or error
const axios = require("axios"); // v1.6+
async function createPOinSAP(po, sapConfig) {
// Fetch CSRF token (required for SAP OData write operations)
const csrf = await axios.get(`${sapConfig.baseUrl}/.../PurchaseOrder`, {
headers: { Authorization: `Bearer ${sapConfig.token}`, "X-CSRF-Token": "Fetch" },
params: { $top: 0 }
});
const csrfToken = csrf.headers["x-csrf-token"];
const response = await axios.post(`${sapConfig.baseUrl}/.../PurchaseOrder`, {
CompanyCode: po.companyCode,
PurchaseOrderType: "NB",
Supplier: po.vendorErpId,
_PurchaseOrderItem: po.lines.map((line, i) => ({
PurchaseOrderItem: String((i + 1) * 10).padStart(5, "0"),
Material: line.materialNumber,
OrderQuantity: String(line.quantity),
NetPriceAmount: String(line.unitPrice),
Plant: line.plant
}))
}, { headers: { "X-CSRF-Token": csrfToken, "If-Match": "*" } });
return { sapPONumber: response.data.PurchaseOrder };
}
# Input: Oracle ERP Cloud credentials, invoice ID
# Output: Invoice match status and variance details
curl -s -X GET \
"${ORACLE_BASE_URL}/fscmRestApi/resources/11.13.18.05/invoices/${INVOICE_ID}" \
-H "Authorization: Bearer ${TOKEN}" | jq '{
invoiceNumber: .InvoiceNumber,
matchStatus: .MatchStatus,
validationStatus: .ValidationStatus,
totalAmount: .InvoiceAmount
}'
| Source Field (Procurement) | SAP Target | Oracle Target | NetSuite Target | Type | Gotcha |
|---|---|---|---|---|---|
| po_number | YY1_ExtPORef (custom) | Attribute15 (DFF) | externalId | String | ERP generates own PO#; store cross-reference |
| supplier_id | Supplier (LIFNR) | Supplier (VENDOR_ID) | entity (vendor internalId) | Lookup | Must resolve to ERP vendor ID |
| line.item_code | Material (MATNR) | ItemNumber | item (internalId) | Lookup | SAP: 18-char zero-padded |
| line.quantity | OrderQuantity | Quantity | quantity | Decimal | UoM must match across systems |
| line.unit_price | NetPriceAmount | Price | rate | Decimal | SAP stores per price unit (e.g., per 100) |
| currency | DocumentCurrency | CurrencyCode | currency.refName | ISO 4217 | Must match ERP currency master |
| ship_to_address | Plant + StorageLocation | ShipToLocationId | location (internalId) | Lookup | Address-to-location mapping required |
| tax_code | TaxCode (MWSKZ) | TaxClassificationCode | taxCode | Lookup | Tax logic differs by ERP + jurisdiction |
| payment_terms | PaymentTerms (ZTERM) | PaymentTermsName | terms (internalId) | Lookup | "Net 30" != "ZN30" in SAP |
| account_code | AccountAssignment | DistributionAccount | account (internalId) | Lookup | GL codes differ across systems |
| Code / Error | Meaning | System | Resolution |
|---|---|---|---|
| CX_BAPI_ERROR 06 315 | Vendor not in purchasing org | SAP | Sync vendor master with purchasing org |
| CX_BAPI_ERROR ME 065 | Material not in plant | SAP | Extend material master to target plant |
| ORA-20001: PO_CREATION_ERR | PO creation failed validation | Oracle | Check required fields (BU, supplier site) |
| INVALID_SEARCH_FILTER | Search syntax error | NetSuite | Check SuiteTalk vs REST search syntax |
| 429 Too Many Requests | Rate limit exceeded | All | Exponential backoff, max 5 retries |
| MATCHING_EXCEPTION | Invoice doesn't match PO/receipt | All AP | Route to exception queue; check tolerances |
| DUPLICATE_VALUE | PO already exists | All | Return existing PO reference (idempotent) |
| CURRENCY_MISMATCH | Invoice currency differs from PO | All AP | Convert at posting rate or reject |
Always sync supplier master first; block PO creation if vendor lookup fails. [src5]Implement a "park" queue for invoices awaiting receipts; auto-retry on receipt event. [src7]Make callback part of same transaction; log for manual reconciliation if callback fails. [src5]Maintain UoM conversion table in middleware; validate both sides before PO creation. [src3]Use ERP tax engine as single source; pass tax-exclusive amounts from procurement. [src1]Store tolerances in middleware config; sync on change. [src5]# BAD — procurement supplier ID is not the ERP vendor ID
sap_po = {"Supplier": procurement_po["supplier_id"]} # "SUP-12345" does not exist in SAP
# GOOD — resolve to ERP vendor ID via cross-reference table
vendor_mapping = get_vendor_crossref(procurement_po["supplier_id"])
sap_po = {"Supplier": vendor_mapping["sap_vendor_id"]} # "0000012345" valid LIFNR
# BAD — retries create duplicate POs
def push_po(po, erp):
return requests.post(f"{erp['url']}/purchase-orders", json=po)
# Timeout + retry = 2 POs for 1 procurement PO
# GOOD — check if PO exists before creating
def push_po(po, erp):
existing = requests.get(f"{erp['url']}/purchase-orders",
params={"external_ref": po["procurement_po_number"]})
if existing.json().get("count", 0) > 0:
return existing.json()["items"][0]
return requests.post(f"{erp['url']}/purchase-orders", json=po).json()
# BAD — 3-way match fails; supplier gets rejection
def process_invoice(invoice, erp):
match_result = erp.three_way_match(invoice) # Fails for same-day deliveries
if not match_result["matched"]:
erp.reject_invoice(invoice) # Supplier escalates
# GOOD — park invoice, subscribe to receipt event, retry
def process_invoice(invoice, erp, event_bus):
receipts = erp.get_receipts_for_po(invoice["po_number"])
if not receipts:
erp.park_invoice(invoice, reason="awaiting_receipt")
event_bus.subscribe(f"receipt.posted.{invoice['po_number']}",
lambda r: retry_match(invoice, erp))
return {"status": "parked"}
return erp.three_way_match(invoice)
Enforce dependency: 1) suppliers, 2) materials, 3) POs. [src5]Store tolerances in middleware config; read dynamically before each match. [src5]Aggregate all receipts per PO line before matching. [src2]Use ERP exchange rate table at invoice posting date. [src1]Implement PO change sync; use version/timestamps to propagate changes. [src3]Always read PriceUnit and divide NetPriceAmount accordingly. [src3]# Check PO sync status in SAP (verify cross-reference)
curl -s "${SAP_BASE}/.../PurchaseOrder?\$filter=YY1_ExtPORef eq 'PROC-PO-12345'" \
-H "Authorization: Bearer ${TOKEN}" | jq '.value[0] | {PurchaseOrder, Supplier, CreationDate}'
# Check goods receipt status against PO
curl -s "${SAP_BASE}/.../MaterialDocumentItem?\$filter=PurchaseOrder eq '4500012345'" \
-H "Authorization: Bearer ${TOKEN}" | jq '.value[] | {MaterialDocument, QuantityInEntryUnit}'
# Oracle: Check invoice match status
curl -s "${ORACLE_BASE}/fscmRestApi/.../invoices?q=InvoiceNumber=${INV_NUM}" \
-H "Authorization: Bearer ${TOKEN}" | jq '.items[0] | {MatchStatus, ValidationStatus}'
# Check middleware dead letter queue
curl -s "${MULESOFT_BASE}/api/v1/dlq/messages?queue=p2p-po-sync&status=unprocessed" \
-H "Authorization: Bearer ${TOKEN}" | jq '.messages | length'
| ERP System | Current API Version | Previous Version | Breaking Changes | Notes |
|---|---|---|---|---|
| SAP S/4HANA Cloud | 2408 (OData v4) | 2308 | PO API migrated v2 to v4 OData | BAPI still supported on-prem |
| Oracle ERP Cloud | 24B (REST) | 23D | Supplier API restructured | FBDI templates updated per release |
| NetSuite | 2024.2 | 2024.1 | REST API expanded PO fields | SuiteTalk SOAP fully supported |
| D365 Finance | 10.0.40 | 10.0.38 | PurchaseOrderHeadersV2 updated | DMF composite entities preferred |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Procurement in separate system from AP/Finance | All P2P within a single ERP | Native ERP P2P workflow |
| >100 POs/day across systems | <10 POs/day, simple matching | Manual process or basic AP tool |
| 3-way match required (regulated, large enterprise) | 2-way match sufficient (services only) | Simplified invoice-to-PO matching |
| Multi-ERP with centralized procurement | Single ERP, single entity | Native procurement module |
| Diverse supplier digital maturity | All suppliers on one EDI standard | EDI-only integration |
| Capability | SAP S/4HANA | Oracle ERP Cloud | NetSuite | D365 Finance |
|---|---|---|---|---|
| PO Creation API | OData v4 / BAPI / IDoc | REST / FBDI | REST / SuiteTalk | OData Data Entities |
| Goods Receipt API | OData (MaterialDocument) | REST (Receipts) | REST (ItemReceipt) | OData (ProductReceipts) |
| Invoice Matching | MIRO (Logistics IV) | Payables Invoice Matching | Vendor Bill Matching | Invoice Matching (AP) |
| 3-Way Match | Built-in (GR-based IV) | Built-in (Match Approval) | Configurable per vendor | Built-in (match policy) |
| Tolerance Config | Per vendor + per material | Per BU + per supplier | Per vendor preference | Per vendor group + policy |
| Bulk Import | IDoc queuing, BDC batch | FBDI (CSV upload) | CSV Import, SuiteImport | DMF |
| Event-Driven | Business Events, RAP | Business Events, OIC | User Event Scripts | Business Events, Dataverse |
| Authentication | SAML/X.509/OAuth | OAuth 2.0 JWT | TBA | Azure AD OAuth 2.0 |
| Price Unit Handling | Per price unit (e.g., per 100) | Per unit (standard) | Per unit (standard) | Per unit (standard) |
| Cross-Ref Field | Custom field (YY1_*) | DFF (Attribute columns) | externalId | Custom field on entity |