Import external charges
Import payments already collected by another system, with partner commissions, events, and safe retries.
Import external charges
Use this endpoint when another system has already collected a payment and Donutwork must record it as a paid charge. It preserves the original amounts, taxes, and dates, and runs the existing partner commission and event flow. It does not contact a payment gateway or require a customer payment method.
For a new payment to be collected by Donutwork, use the one-time charge endpoint instead. Imported charges are independent one-shot transactions: they do not create or renew a subscription.
Endpoint and prerequisites
POST https://api.hub.donutwork.com/2026-02-01/customers/CUSTOMER_ID/charges/import.json
Authorization: Bearer YOUR_API_KEY
Content-Type: application/jsonThe endpoint also supports version 2024-08-01. Use the API base URL and key for your environment; never send a development key to production. Read authentication for key setup.
- The server-side API key needs
charges:write; reading the result requirescharges:read. CUSTOMER_IDmust be an existing 24-character hexadecimal customer ID in the company associated with the key. Company identity comes from the key, not the JSON body.- Each line must reference a tax profile belonging to that same company. Use
GET /taxes.jsonwithtaxes:readto select it; see tax profiles. currencymust match the company currency configured in Partner Portal. Without a company currency configuration, only EUR imports for customers without a partner are accepted.- If the customer has a partner, that partner and its commission group must be valid and the company currency configuration must be persisted. Partner attribution is resolved by the server from the customer.
Request body
Save the following example as charge-import.json. Replace the sample tax profile ID with your own company-scoped ID and use the actual creation and payment dates of the external transaction. All IDs and transaction references in this guide are examples.
{
"charge": {
"source": "external-billing",
"external_id": "transaction-123",
"created_at": "2026-10-20T10:00:00Z",
"paid_at": "2026-10-20T10:05:00Z",
"currency": "EUR",
"total": "122.00",
"services": [
{
"name": "External consulting service",
"amount": "100.00",
"taxAmount": "22.00",
"taxProfile": "67248941cbe2b1ff8b099a6d"
}
]
}
}Send it from a trusted backend or shell where DONUTWORK_API_KEY is already set:
curl -i -X POST \
'https://api.hub.donutwork.com/2026-02-01/customers/CUSTOMER_ID/charges/import.json' \
-H "Authorization: Bearer $DONUTWORK_API_KEY" \
-H 'Content-Type: application/json' \
--data-binary @charge-import.jsonThe body must contain only the charge object and must not exceed 64 KiB. Every field below is required.
| Field | Type | Contract |
|---|---|---|
charge.source | string | Stable source-system name, up to 80 UTF-8 bytes. Keep the same value across retries. |
charge.external_id | string | Stable transaction ID in that source, up to 200 UTF-8 bytes. Unique with source across the company, including across customers. |
charge.created_at | string | Original charge creation time in ISO 8601 with timezone, such as 2026-10-20T10:00:00Z. |
charge.paid_at | string | Original payment time in the same format; cannot precede created_at. |
charge.currency | string | Three-letter currency code matching the company currency. |
charge.total | number or decimal string | Positive gross total, exactly equal to the sum of all line amounts and taxes. |
charge.services | array | Between 1 and 100 line objects. |
charge.services[].name | string | Non-empty line description, up to 250 UTF-8 bytes. |
charge.services[].amount | number or decimal string | Positive net amount, excluding tax. |
charge.services[].taxAmount | number or decimal string | Tax already applied externally; zero is allowed. |
charge.services[].taxProfile | string | Existing 24-character hexadecimal tax profile ID in this company. |
Amounts are in major currency units, with a decimal point, at most two decimal places and eight integer digits. For example, "100.00" means one hundred currency units. Do not send minor units such as 10000 for that amount. The server preserves taxAmount; it does not recalculate historical tax using the tax profile's current percentage.
Dates must be valid, at or after the Unix epoch, non-future, and include a timezone; up to three fractional-second digits are accepted. They are normalized to UTC. Text fields are trimmed and must not contain control characters.
Unknown fields are rejected with 422. Do not include company_id, customer_id, partner_id, status, instruction, capture, or invoice: customer identity comes from the URL, partner identity from the customer, and imported charges are always already paid. Invoice metadata can be attached separately after import.
Responses and retries
A first successful import returns 201 Created:
{
"status": "success",
"id": "CHARGE_ID",
"created": true,
"paid": true,
"externally_managed": true
}Save id alongside your source transaction. A completed replay with the same normalized payload returns 200 OK, the same id, and created: false. No Idempotency-Key header is needed.
The idempotency key is the company plus source and external_id. The payload comparison includes the customer, both dates, currency, total, and line items in their original order. Reusing that key with different content returns 409; it does not update the imported transaction. Keep the original payload for retries instead of generating new dates or another external ID after a timeout.
| HTTP status | Meaning and action |
|---|---|
200 | Existing import returned or an interrupted import completed; reuse the returned charge ID. |
201 | New import completed. |
400 | Missing or malformed JSON body. |
401 / 403 | Authentication or authorization failed; check the key, company access, and charges:write. |
404 | Customer not found in the authenticated company. |
409 | Different content for the same source transaction, or another request is finalizing this import. Distinguish them by the error message. |
413 | Body exceeds 64 KiB. |
415 | Content type is not application/json. |
422 | Invalid fields, dates, amounts, currency, tax profile, partner/group, or total. Correct the input before retrying. |
429 | API rate limit reached; follow the response's retry guidance. |
503 | Import cannot be finalized, or partner currency configuration is missing. Resolve configuration issues first; otherwise retry the same source, external ID, and payload with backoff. |
Examples of the two 409 messages:
{"error":"source and external_id already used with different content"}{"error":"Import is being finalized; retry the same payload"}An import may already have saved the charge when a network interruption or 503 occurs. Repeating the identical request resumes unfinished work without creating another charge or direct commission. A concurrent finalization returns 409; retry with backoff. Recovery after an interrupted process can require waiting for its five-minute processing lease to expire. A recovered import may return 200 even if you never received its original 201.
Commissions, events, and external ownership
The customer's partner is captured at first insertion. Its direct commission uses the charge's net amount and the existing partner group rules; its accounting date follows paid_at. Default zero-percent rules can produce no positive commission. Eligible referral commissions are processed asynchronously under the company referral policy. Direct rules are those available when calculated; the referral policy is the one effective when the direct fee is created. Importing old payments does not reconstruct historical commission configurations or recalculate existing fees.
After saving the paid charge and processing its direct commission, Donutwork emits Charge.create and Charge.succeeded through the usual event flow. Configured workflows and webhooks can therefore run for imported payments. The import does not send an automatic payment receipt email or contact Stripe, PayPal, or another gateway.
A completed replay does not generate another commission or re-emit these events. Event delivery uses the existing dispatcher guarantees: a crash between dispatch and its completion checkpoint can repeat an event. Make webhook consumers idempotent using the event type and charge ID, and reconcile with the charge's current state. See webhooks.
Imported charge amounts, payment dates, and state are managed by the external source. Donutwork's capture and manual status-change paths cannot collect, mark, or refund them. This import endpoint does not provide an update, deletion, or refund operation. Reposting modified amounts or dates under the same source transaction returns 409; a different external ID represents another transaction, not a correction.
Read and identify an imported charge
Read the returned ID with charges:read:
curl \
'https://api.hub.donutwork.com/2026-02-01/customers/CUSTOMER_ID/charges/CHARGE_ID.json' \
-H "Authorization: Bearer $DONUTWORK_API_KEY"Relevant fields in the charge detail response include:
{
"id": "CHARGE_ID",
"type": "one-shot",
"status": "paid",
"instruction": "external",
"externally_managed": true,
"paid_at": "2026-10-20T10:05:00+00:00",
"metadata": {
"paid": {
"source": "external",
"external_source": "external-billing",
"external_id": "transaction-123",
"currency": "EUR"
}
}
}The company charge list also supports GET /charges.json?instruction=external. List summaries include instruction, externally_managed, and the original creation date; use charge details for the external references. The backoffice customer ledger shows an import badge with the source system in its tooltip.
To attach invoice number, date, source, or link, use PUT /customers/{customerId}/charges/{chargeId}/invoice.json with charges:write. This updates only invoice metadata, without changing the external payment. See invoice synchronization.
Structured endpoint reference
charges:writePath, query, and header parameters
customerIdRequiredRequest Body
{
"charge": {
"source": "external-billing",
"external_id": "transaction-123",
"created_at": "2026-10-20T10:00:00Z",
"paid_at": "2026-10-20T10:05:00Z",
"currency": "EUR",
"total": "122.00",
"services": [
{
"name": "External consulting service",
"amount": "100.00",
"taxAmount": "22.00",
"taxProfile": "67248941cbe2b1ff8b099a6d"
}
]
}
}chargeRequiredcharge.sourceRequiredcharge.external_idRequiredcharge.created_atRequiredcharge.paid_atRequiredcharge.currencyRequiredcharge.totalRequiredcharge.servicesRequiredResponses
New external payment recorded.
{
"status": "success",
"id": "CHARGE_ID",
"created": true,
"paid": true,
"externally_managed": true
}Identical replay or completed recovery.
{
"status": "success",
"id": "CHARGE_ID",
"created": false,
"paid": true,
"externally_managed": true
}Payload conflict; concurrent finalization also returns 409, as explained above.
{
"error": "source and external_id already used with different content"
}Request examples
curl --request POST 'https://api.hub.donutwork.com/2026-02-01/customers/CUSTOMERID/charges/import.json' \
--header 'Authorization: Bearer YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data '{
"charge": {
"source": "external-billing",
"external_id": "transaction-123",
"created_at": "2026-10-20T10:00:00Z",
"paid_at": "2026-10-20T10:05:00Z",
"currency": "EUR",
"total": "122.00",
"services": [
{
"name": "External consulting service",
"amount": "100.00",
"taxAmount": "22.00",
"taxProfile": "67248941cbe2b1ff8b099a6d"
}
]
}
}'const response = await fetch('https://api.hub.donutwork.com/2026-02-01/customers/CUSTOMERID/charges/import.json', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.DONUTWORK_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"charge": {
"source": "external-billing",
"external_id": "transaction-123",
"created_at": "2026-10-20T10:00:00Z",
"paid_at": "2026-10-20T10:05:00Z",
"currency": "EUR",
"total": "122.00",
"services": [
{
"name": "External consulting service",
"amount": "100.00",
"taxAmount": "22.00",
"taxProfile": "67248941cbe2b1ff8b099a6d"
}
]
}
}),
});
const requestId = response.headers.get('x-request-id');
if (!response.ok) {
const error = await response.text();
throw new Error(`${response.status} ${error} [${requestId}]`);
}
const contentType = response.headers.get('content-type') ?? '';
const data = response.status === 204 ? null
: contentType.includes('application/json') ? await response.json()
: await response.text();$request = curl_init('https://api.hub.donutwork.com/2026-02-01/customers/CUSTOMERID/charges/import.json');
curl_setopt_array($request, [
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('DONUTWORK_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => '{
"charge": {
"source": "external-billing",
"external_id": "transaction-123",
"created_at": "2026-10-20T10:00:00Z",
"paid_at": "2026-10-20T10:05:00Z",
"currency": "EUR",
"total": "122.00",
"services": [
{
"name": "External consulting service",
"amount": "100.00",
"taxAmount": "22.00",
"taxProfile": "67248941cbe2b1ff8b099a6d"
}
]
}
}',
]);
$response = curl_exec($request);
if ($response === false) {
$error = curl_error($request);
curl_close($request);
throw new RuntimeException($error);
}
$status = curl_getinfo($request, CURLINFO_RESPONSE_CODE);
$contentType = (string) curl_getinfo($request, CURLINFO_CONTENT_TYPE);
curl_close($request);
if ($status >= 400) { throw new RuntimeException($response); }
$data = $status === 204 ? null
: (str_contains($contentType, 'application/json')
? json_decode($response, true, flags: JSON_THROW_ON_ERROR) : $response);Replace uppercase placeholders and sample values with your own data and company resource IDs. Keep the API key on a trusted server. Reuse an idempotency key only for the same logical request and payload.