Overview
The Partner API lets external integrators submit shipping documents (invoice, packing list, bill of lading) for automated extraction and discrepancy detection. Processing is asynchronous: you POST documents to /api/v1/partner/submit, and ShippedFlow delivers the extracted structured data to a webhook URL you provide.
How it works
- You POST documents and a
correlationId(a UUID you generate) to our submit endpoint. We return202 Accepted. - We process the documents asynchronously using our OCR and AI pipeline.
- We POST the structured results to your
webhookUrl, with yourcorrelationIdechoed back at the top of the payload.
Audience: this API is for backend-to-backend integrations. The general API documentation (form-based submissions, MDC pipeline) lives at the main docs site.
Authentication
Every request to the Partner API must include a Bearer token issued by ShippedFlow:
Authorization: Bearer <your_api_key>
Per-customer billing
You receive a single API key. Each request includes a customerRef field — an opaque string identifying which of your end-customers caused the request. ShippedFlow uses it only for billing aggregation; we never treat it as a credential. You can use any string format you like (UUID, slug, name, etc.) up to 255 characters.
Why one key, not per-customer keys? Authentication identifies you (the integrator); customerRef identifies who pays for the job. Decoupling these means you manage one credential and never have to rotate per-customer secrets.
Submit Endpoint
Submit a set of documents for processing. Returns immediately with 202 Accepted; the actual results arrive later via webhook.
Request Headers
| Header | Required | Description |
|---|---|---|
| Authorization | required | Bearer <api_key> |
| Content-Type | required | application/json |
| Idempotency-Key | optional | UUID string. If repeated, returns the original response instead of re-processing. Useful for safely retrying after network failures. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| correlationId | string (UUID) | required | UUID v4 you generate. Echoed back in the webhook so you can thread response to request. |
| customerRef | string (1–255 chars) | required | Your end-customer identifier. Used only for billing aggregation; format is up to you. |
| webhookUrl | string (HTTPS URL) | required | Where ShippedFlow will POST the processing results. |
| operationType | string | optional | IMPORT or EXPORT. Advisory hint. |
| documents | array | required | 1–20 documents to process. Total request body ≤ 50 MB. |
| documents[].name | string (1–255 chars) | required | Original filename (used in logs and S3 keys). |
| documents[].type | string | optional | INVOICE, PACKING_LIST, BILL_OF_LADING, or OTHER. Advisory; our pipeline classifies internally. |
| documents[].filedata | string (base64) | required | File contents, base64-encoded. ≤ 10 MB decoded per file. |
Example Request
POST /api/v1/partner/submit HTTP/1.1
Host: api.shippedflow.info
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
Idempotency-Key: 7f3a9c2b-1d4e-4b8a-9c1f-2e5d6a7b8c9d
{
"correlationId": "550e8400-e29b-41d4-a716-446655440000",
"customerRef": "futurbano-sl",
"webhookUrl": "https://your-system.example.com/webhooks/shippedflow",
"operationType": "IMPORT",
"documents": [
{
"name": "invoice.pdf",
"type": "INVOICE",
"filedata": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5n..."
},
{
"name": "packing-list.pdf",
"type": "PACKING_LIST",
"filedata": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5n..."
}
]
}Success Response
202 Accepted — request validated and queued for processing.
{
"status": "accepted",
"correlationId": "550e8400-e29b-41d4-a716-446655440000",
"customerRef": "futurbano-sl",
"jobId": "sf-job-7f3a9c2b1d4e",
"receivedAt": "2026-05-31T14:23:00Z"
}Webhook Delivery
When processing completes (success or failure), ShippedFlow POSTs the result to the webhookUrl you provided.
Webhook Request
Method: POST
Headers we send:
| Header | Description |
|---|---|
| Content-Type | application/json |
| X-Shippedflow-Signature | sha256=<hex> — HMAC-SHA256 of the raw body using your shared secret. You must verify this before trusting the payload. |
| X-Shippedflow-Delivery-Id | UUID, unique per delivery attempt. Use it to deduplicate if you receive retries. |
| User-Agent | Shippedflow-Webhook/1.0 |
Webhook Body
| Field | Type | Description |
|---|---|---|
| correlationId | string | Echoed from your submit request — your thread identifier. |
| customerRef | string | Echoed from your submit request. |
| status | string | success, error, or partial. |
| jobId | string | Same value as in our submit response. Useful for support. |
| completedAt | string (ISO 8601) | When processing finished on our side. |
| summary | object | Aggregated totals (currency, total amount, weights, packages). See the results JSON schema for full structure. |
| invoice_details | object | Header-level metadata (exporter, consignee, vessel, ports, container numbers). |
| data | array | One entry per line item: quantities, prices, TARIC code, classification confidence. |
| discrepancies | array | AI-detected mismatches between documents. Closed list of Type values: Consolidation mismatch, Sum mismatch, Quantity mismatch, Missing data, Other. Treat any unknown future value as Other. |
| error | object | Present only when status = error. Contains {code, message}. |
Missing or unknown values: JSON null is used across every field — both string fields and numeric fields — when the source documents don't carry the data. Type stability is preserved per field (e.g. summary.total_amount is number | null, invoice_details.vessel is string | null). Use the canonical value === null check rather than empty-string or string-sentinel comparisons.
Error Codes (when status = "error")
When processing fails permanently, the webhook arrives with status: "error" and an error object containing a code and a human-readable message. The code is a closed list — treat any unknown future value as internal_error.
{
"correlationId": "550e8400-e29b-41d4-a716-446655440000",
"customerRef": "your-ref",
"status": "error",
"jobId": "sf-job-550e8400",
"completedAt": "2026-06-14T10:23:00.000Z",
"error": {
"code": "submission_too_large",
"message": "Combined document size exceeds processing limit. Please split the submission into smaller batches."
}
}error.code | When | Recommended partner action |
|---|---|---|
| submission_too_large | The combined documents exceeded our processing budget during analysis (input is larger than the model can handle). | Split the submission into smaller batches and resubmit. |
| document_unreadable | One or more documents could not be parsed: corrupt PDF, OCR failure, or the model failed to extract structured content. | Verify file integrity (the PDF opens cleanly outside our system) and resubmit. If the file is fine, contact support. |
| incomplete_submission | The submission is missing required document types for the declared operationType. IMPORT requires Invoice + Packing List + Bill of Lading. EXPORT requires Invoice + Packing List. |
Include all required document types in a single submission and resubmit. |
| documents_mismatch | The submitted documents appear to belong to different shipments — large inconsistencies were detected between invoice and packing list totals (quantities, weights, packages, or volume) that go beyond normal rounding. | Verify that the invoice and packing list belong to the same shipment and resubmit. |
| unsupported_content | The submission contains content we cannot currently process (unsupported language, document type, or layout). | Contact support — we'll triage whether the case fits the current scope or needs to land in our backlog. |
| processing_timeout | Processing exceeded the maximum allowed time (transient issue or unusually slow upstream). | Resubmit. If the problem persists for the same submission, contact support. |
| internal_error | Catch-all for any unmapped failure on our side. | Resubmit later. If the problem persists, contact support with the correlationId so we can trace it. |
Privacy of error details: the message field is a stable, generic description tied to the code. We never expose internal details (model names, token counts, stack traces) to partners. Match on code for automation, surface message to end users.
Expected Response
Your endpoint should return any 2xx status within 30 seconds. Response body is ignored.
Retry policy: on non-2xx or timeout, we retry at 5 minutes, 30 minutes, and 2 hours. After the third failure we mark the submission as ERROR and stop. Use X-Shippedflow-Delivery-Id to deduplicate retries.
Verifying the HMAC Signature
The signature proves the webhook came from ShippedFlow and the body wasn't modified in transit. Compute HMAC-SHA256 of the raw request body using your shared secret, and compare to the value in X-Shippedflow-Signature:
Python (Flask)
import hmac
import hashlib
from flask import request, abort
SHIPPEDFLOW_HMAC_SECRET = "your-shared-secret-here"
def verify_shippedflow_signature():
received = request.headers.get("X-Shippedflow-Signature", "")
if not received.startswith("sha256="):
abort(401)
expected = "sha256=" + hmac.new(
SHIPPEDFLOW_HMAC_SECRET.encode(),
request.get_data(), # raw body, NOT request.get_json()
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(received, expected):
abort(401)Node.js (Express)
const crypto = require("crypto");
const SHIPPEDFLOW_HMAC_SECRET = "your-shared-secret-here";
function verifyShippedflowSignature(req) {
const received = req.header("X-Shippedflow-Signature") || "";
if (!received.startsWith("sha256=")) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", SHIPPEDFLOW_HMAC_SECRET)
.update(req.rawBody) // raw body buffer, NOT req.body parsed
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(received),
Buffer.from(expected)
);
}Important: verify against the raw request body bytes, not the re-serialized JSON. Most frameworks parse and re-serialize JSON, which can change whitespace and break the signature.
Error Responses
All error responses share this shape:
{
"error": "validation_failed",
"message": "documents[2].filedata is not valid base64",
"correlationId": "550e8400-e29b-41d4-a716-446655440000"
}The correlationId is echoed when present so you can still match errors to requests.
| HTTP | error code | When |
|---|---|---|
| 400 | invalid_request | Malformed JSON or missing required field. |
| 401 | unauthorized | Missing or invalid API key. |
| 409 | duplicate_correlation_id | This correlationId was already accepted. The original jobId is returned in the body so you can keep using it. |
| 413 | payload_too_large | A file exceeded 10 MB (decoded), the request exceeded 50 MB total, or you sent more than 20 documents. |
| 415 | unsupported_file_type | A file's content isn't a supported format (currently PDF only). |
| 422 | validation_failed | A field is present but malformed (bad UUID, non-HTTPS webhook, invalid base64, etc.). |
| 429 | rate_limited | You're sending requests too quickly. Retry with backoff. |
| 500 | internal_error | Something went wrong on our side. Safe to retry after a short delay. |
Examples
cURL
curl -X POST https://api.shippedflow.info/api/v1/partner/submit \
-H "Authorization: Bearer $SHIPPEDFLOW_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d @- <<EOF
{
"correlationId": "$(uuidgen)",
"customerRef": "futurbano-sl",
"webhookUrl": "https://your-system.example.com/webhooks/shippedflow",
"operationType": "IMPORT",
"documents": [
{
"name": "invoice.pdf",
"type": "INVOICE",
"filedata": "$(base64 -i invoice.pdf)"
}
]
}
EOFPython
import base64
import os
import uuid
import requests
API_KEY = os.environ["SHIPPEDFLOW_API_KEY"]
API_BASE = "https://api.shippedflow.info"
def submit(pdf_paths, customer_ref, webhook_url):
documents = []
for path in pdf_paths:
with open(path, "rb") as f:
documents.append({
"name": os.path.basename(path),
"type": "INVOICE", # or PACKING_LIST / BILL_OF_LADING
"filedata": base64.b64encode(f.read()).decode("ascii"),
})
correlation_id = str(uuid.uuid4())
response = requests.post(
f"{API_BASE}/api/v1/partner/submit",
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"correlationId": correlation_id,
"customerRef": customer_ref,
"webhookUrl": webhook_url,
"operationType": "IMPORT",
"documents": documents,
},
timeout=30,
)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
result = submit(
["invoice.pdf", "packing-list.pdf"],
customer_ref="futurbano-sl",
webhook_url="https://your-system.example.com/webhooks/shippedflow",
)
print(f"Accepted: jobId={result['jobId']} correlationId={result['correlationId']}")Webhook receiver (Python / Flask)
import hmac
import hashlib
import os
from flask import Flask, request, jsonify, abort
app = Flask(__name__)
SECRET = os.environ["SHIPPEDFLOW_HMAC_SECRET"].encode()
@app.route("/webhooks/shippedflow", methods=["POST"])
def shippedflow_webhook():
# 1. Verify signature against raw body
received = request.headers.get("X-Shippedflow-Signature", "")
expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(received, expected):
abort(401)
# 2. Deduplicate by delivery id (in case of retries)
delivery_id = request.headers.get("X-Shippedflow-Delivery-Id")
if already_processed(delivery_id):
return jsonify({"status": "duplicate_ignored"}), 200
# 3. Process payload
payload = request.get_json()
save_result(payload["correlationId"], payload)
return jsonify({"status": "ok"}), 200Test Environment
Use the preprod environment for integration development and testing. It runs the same code as production but against a separate database and S3 bucket.
| Environment | API base URL |
|---|---|
| Production | https://api.shippedflow.info |
| Preprod (test) | https://test-api.shippedflow.info |
You'll receive separate API keys and HMAC secrets for each environment.
Need credentials or have questions? Contact your ShippedFlow account contact to provision API keys, webhook secrets, and any environment-specific configuration.