API to connect your ERP
Everything the Sourevo app does, without a screen: your ERP creates products and suppliers, uploads plots, turns each goods receipt into a batch, prepares the due diligence statement (DDS), submits it to TRACES and gets the reference number back by webhook.
OpenAPI 3.1 document · https://sourevo.com/api/v1
The flow, step by step
Designed for the Odoo module, and valid for any ERP or forwarder software:
- Products: create each EUDR item with its CN code (and the scientific name for wood).
- Suppliers: sync your contacts with their externalRef and invite them to their portal; follow their trust level.
- Plots: the supplier uploads them in its portal, or you upload the ones it sent you as a proposal for it to confirm.
- Batch: each goods receipt in the ERP is a batch with its quantity and its origin plots.
- DDS: create the draft from the batch and run the pre-submission check (blocking issues and warnings).
- Submission: acknowledge the warnings and submit to TRACES.
- Reference: TRACES returns the reference number and the dds.status_changed webhook brings it to your ERP.
Plans and simulated mode
The API is included in the Pro, Enterprise and Forwarder plans at no extra cost. Packs do not include it: calls answer 403 plan_not_allowed.
During the 14-day trial keys work in simulated mode: statements go to the Sourevo simulated TRACES and get references starting with SIM. They never reach the Commission.
Every response carries the X-Sourevo-Mode header (live or simulated) and GET /me returns the mode and the plan. Every DDS carries simulated: true or false.
When the trial or the plan ends, keys answer 403 plan_not_allowed with details.reason trial_expired or plan_expired.
Authentication
Every request carries the header Authorization: Bearer svk_... with a key of your organization.
Authorization: Bearer svk_...
X-Sourevo-Client: Odoo 18Keys are created in the app, under API and integrations, by owners and administrators only. They are shown once: we only keep their fingerprint.
A key acts as the person who created it. If they leave the organization or stop being an administrator, the key answers 403 key_owner_forbidden. The first use of each key is notified by email.
Optional: the X-Sourevo-Client header (for example "Odoo 18") says which program is calling; it shows in the first-use email.
Rate limit
Up to 120 requests per minute per key. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining; above it, 429 rate_limited with Retry-After in seconds.
Errors
Every error has the same shape, in English: { "error": { "code", "message", "details" } }. Program against code and details.reason, never against the message text.
HTTP/1.1 422 Unprocessable Entity
{
"error": {
"code": "unprocessable",
"message": "Every warning must be acknowledged: send their keys in acknowledgedWarnings.",
"details": {
"reason": "acknowledgement_required",
"blocking": [],
"warnings": [{ "key": "supplier:cm3s...:SUPPLIER_TRUST_BELOW_VALIDATED", "code": "SUPPLIER_TRUST_BELOW_VALIDATED", "level": "supplier", "entityId": "cm3s..." }]
}
}
}| HTTP | code | Meaning |
|---|---|---|
| 400 | validation_error | Malformed request or an invalid field (see details). |
| 400 | invalid_webhook_url | The webhook URL is not https or points to an internal network. |
| 401 | missing_api_key | No Authorization header with a key. |
| 401 | invalid_api_key | The key does not exist or has been revoked. |
| 403 | plan_not_allowed | The plan does not include the API (Packs), or the trial or plan has ended. |
| 403 | key_owner_forbidden | The key creator left the organization or is no longer an owner or administrator. |
| 403 | forbidden | The role behind the key cannot do this, or the resource belongs to another organization. |
| 404 | not_found | It does not exist in your organization. |
| 409 | conflict | The state of the resource does not allow it (see details.reason). |
| 409 | idempotency_in_progress | Another request with the same Idempotency-Key is still running. |
| 413 | payload_too_large | The body is larger than 2 MB. |
| 422 | idempotency_key_reused | That Idempotency-Key was already used with a different request. |
| 422 | unprocessable | A business rule prevents it (see details.reason). |
| 429 | rate_limited | More than 120 requests per minute with this key. |
| 500 | internal_error | Unexpected error on our side; it is logged. |
| 502 | upstream_error | TRACES could not be reached. Retry with the same Idempotency-Key. |
| 503 | service_unavailable | An optional service (satellite check) is not configured. |
Reasons (details.reason)
Business rules answer 422 unprocessable (or 409 conflict, 400 validation_error) with a details.reason from this closed list, in snake_case, plus the data you need to build your own message. For example, pre_submission_blocked carries blocking and warnings, and traces_rejected carries the Commission codes in rules.
acknowledgement_requiredarea_required_for_pointbatch_frozenbatch_has_live_ddsbusiness_rulecoordinates_incompleteexternal_ref_takengeojson_emptygeojson_feature_without_geometrygeojson_invalid_geometrygeojson_invalid_jsongeojson_not_wgs84geojson_polygon_invalidgeojson_polygon_not_closedgeojson_polygon_too_few_verticesgeojson_too_many_verticesgeojson_unsupported_geometrygeometry_requiredinternal_reference_takeninvalid_addressinvalid_areainvalid_cn_codeinvalid_countryinvalid_cursorinvalid_dateinvalid_emailinvalid_external_refinvalid_idempotency_keyinvalid_inputinvalid_jsoninvalid_legal_nameinvalid_limitinvalid_nameinvalid_phoneinvalid_quantityinvalid_statusinvalid_tax_idinvalid_verificationkey_already_revokedlatitude_out_of_rangelongitude_out_of_rangeno_satellite_checknot_a_draftoperator_without_credentialspack_planplan_expiredplan_limit_reachedplot_not_foundplot_rejectedplot_without_geometrypolygon_required_over_4_hapre_submission_blockedquantity_requiredreason_requiredretract_reason_requiredrole_not_allowedscientific_name_requiredstorage_unavailablesubmission_in_progresssupplier_already_claimedsupplier_not_ownedtax_id_declared_by_suppliertoo_many_webhookstraces_faulttraces_permission_deniedtraces_rejectedtrial_expiredtrial_simulated_only
Idempotency
Every POST accepts the Idempotency-Key header (up to 255 characters). Always use it when submitting: if the connection drops, retry with the same key without fear of submitting twice.
Same key and same request within 24 hours: we return the stored response with the header Idempotent-Replayed: true, repeating nothing.
Same key with another body or path: 422 idempotency_key_reused.
While the first one is still running: 409 idempotency_in_progress. 5xx responses are not stored: the retry runs again.
Pagination and your ERP references
Lists return { "data": [...], "nextCursor": "..." }. Ask for the next page with ?cursor=<nextCursor>; limit goes from 1 to 100 (50 by default). On the last page nextCursor is null.
Suppliers, products and batches accept externalRef, the id in your ERP (for example the goods receipt WH/IN/00012). A POST with an externalRef that already exists creates nothing: it returns the existing one with 200 and duplicate: true. You can search with ?externalRef=.
GET /dds filters by status, operatorId, batchId, internalReferenceNumber and updatedSince (to sync by polling if you do not use webhooks).
Endpoints
Base: /api/v1. The full reference, with every field and schema, is in the OpenAPI document.
| Method | Path | What it does |
|---|---|---|
| GET | /me | Organization, plan, mode and key (use it to test the connection) |
| GET | /operators | Companies that submit to TRACES |
| GET | /suppliers | List suppliers |
| POST | /suppliers | Create a supplier |
| GET | /suppliers/{id} | Get a supplier |
| PATCH | /suppliers/{id} | Update a supplier you own (partial) |
| GET | /suppliers/{id}/status | Trust level, plots summary, account and invitation |
| POST | /suppliers/{id}/invitations | Invite the supplier to its portal (emailed when it has an address) |
| GET | /suppliers/{id}/plots | Plots of a supplier your organization can use |
| POST | /suppliers/{id}/plots | Upload plots as your proposal to the supplier |
| GET | /plots/{id} | Get a plot |
| POST | /plots/{id}/verify | Verify (approve) the plot for your organization |
| POST | /plots/{id}/correction-request | Ask the supplier to correct the plot ({ reason }) |
| POST | /plots/{id}/satellite-checks | Run a satellite deforestation check now |
| GET | /plots/{id}/satellite-checks/latest | Latest satellite check by your organization |
| GET | /products | List products |
| POST | /products | Create a product (CN code; scientific name for wood) |
| GET | /products/{id} | Get a product |
| GET | /batches | List batches |
| POST | /batches | Create a batch (a goods receipt) and link its plots |
| GET | /batches/{id} | Get a batch |
| POST | /batches/{id}/plots | Link plots ({ plotIds }) |
| DELETE | /batches/{id}/plots/{plotId} | Unlink a plot |
| GET | /dds | List due diligence statements |
| POST | /dds | Create a draft from a batch |
| GET | /dds/{id} | Get a DDS (status, reference and verification numbers) |
| PATCH | /dds/{id} | Change a draft |
| DELETE | /dds/{id} | Delete a draft |
| POST | /dds/{id}/risk-assessment | Record the risk assessment of a draft |
| GET | /dds/{id}/check | Pre-submission check: blocking issues and warnings |
| POST | /dds/{id}/submit | Submit to TRACES ({ acknowledgedWarnings }) |
| POST | /dds/{id}/amend | Amend a submitted DDS (activityType, countries, comment, geoLocationConfidential) |
| POST | /dds/{id}/retract | Retract a submitted DDS ({ reason }) |
| POST | /dds/{id}/refresh | Ask TRACES for the current status now |
| GET | /webhooks | List webhook endpoints |
| POST | /webhooks | Register a webhook endpoint ({ url, events?, description? }) |
| DELETE | /webhooks/{id} | Delete a webhook endpoint |
| POST | /webhooks/{id}/test | Send a signed ping now |
| GET | /webhooks/{id}/deliveries | Delivery log of an endpoint |
Webhooks
Your ERP registers its URL with POST /webhooks (https, never an internal network) and gets the secret once. Each notice is a POST with the event as JSON.
Events
| type | |
|---|---|
dds.submitted | A DDS was submitted to TRACES (from the web, the API or the sync). |
dds.status_changed | A DDS changes status: reference received (AVAILABLE), rejected, withdrawn or amended. |
plot.proposal_answered | The supplier confirmed, corrected or rejected a plot you uploaded as a proposal. |
plot.satellite_flagged | A satellite check found tree cover loss: it needs review. |
supplier.claimed | The supplier created its account and claimed the record you created. |
plan.quota_80 | You have used 80% of the statements included in your plan. |
plan.quota_100 | You have used every included statement; the next ones are extra. |
POST /webhooks/{id}/test sends a signed ping event to test the connection.
Body of each notice: { "id", "type", "createdAt", "data" }. data carries the object in the same shape as in the API (dds, plot, supplier...). Headers: X-Sourevo-Event, X-Sourevo-Delivery (delivery id) and X-Sourevo-Signature.
{
"id": "cm3x...",
"type": "dds.status_changed",
"createdAt": "2026-12-30T09:14:02.000Z",
"data": {
"previousStatus": "SUBMITTED",
"status": "AVAILABLE",
"dds": { "id": "cm3w...", "status": "AVAILABLE", "referenceNumber": "26ESXXXXXXXXXX", "verificationNumber": "XXXXXXXX", "simulated": false }
}
}Signature: X-Sourevo-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 with your secret of "<t>.<raw body>". Refuse notices more than 5 minutes off.
Any 2xx response confirms the delivery. Otherwise we retry after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h; then the delivery is failed. No redirects and a 10-second timeout.
GET /webhooks/{id}/deliveries returns the delivery log with attempts, last HTTP status and error.
Verifying the signature
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody: the request body exactly as received (string).
export function isFromSourevo(secret, rawBody, header) {
const parts = Object.fromEntries(header.split(',').map((x) => x.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, 'hex'));
}import hashlib, hmac, time
def is_from_sourevo(secret: str, raw_body: bytes, header: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > 300:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))curl examples
Replace svk_... with your key. Ids come from the previous responses.
curl https://sourevo.com/api/v1/me \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18"curl -X POST https://sourevo.com/api/v1/products \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18" \
-H "Content-Type: application/json" \
-d '{"description":"Green coffee","cnCode":"0901 11 00","externalRef":"product_42"}'curl -X POST https://sourevo.com/api/v1/suppliers \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18" \
-H "Content-Type: application/json" \
-d '{"name":"Finca La Esperanza","legalName":"Finca La Esperanza S.A.S.","country":"CO","taxId":"900123456","email":"finca@example.com","postalAddress":"Vereda El Placer, Pitalito, Huila","externalRef":"res.partner_7"}'curl -X POST https://sourevo.com/api/v1/suppliers/SUPPLIER_ID/plots \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18" \
-H "Content-Type: application/json" \
-d '{"name":"Lote 1","country":"CO","areaHa":2.5,"latitude":1.85,"longitude":-76.05}'curl -X POST https://sourevo.com/api/v1/batches \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18" \
-H "Content-Type: application/json" \
-d '{"productId":"PRODUCT_ID","netWeightKg":18000,"externalRef":"WH/IN/00012","plotIds":["PLOT_ID"]}'curl -X POST https://sourevo.com/api/v1/dds \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18" \
-H "Content-Type: application/json" \
-d '{"batchId":"BATCH_ID","operatorId":"OPERATOR_ID","internalReferenceNumber":"WH-IN-00012","activityType":"IMPORT","operatorRole":"OPERATOR","countryOfActivity":"ES","riskAssessment":{"conclusion":"NEGLIGIBLE","notes":"Verified plots, satellite check without loss."}}'curl https://sourevo.com/api/v1/dds/DDS_ID/check \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18"curl -X POST https://sourevo.com/api/v1/dds/DDS_ID/submit \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: submit-WH-IN-00012" \
-d '{"acknowledgedWarnings":["WARNING_KEY"]}'curl -X POST https://sourevo.com/api/v1/webhooks \
-H "Authorization: Bearer svk_..." \
-H "X-Sourevo-Client: Odoo 18" \
-H "Content-Type: application/json" \
-d '{"url":"https://erp.example.com/sourevo/webhook","events":["dds.status_changed","plot.proposal_answered"]}'Create an account: the 14-day trial includes the API in simulated mode.
Create a free account