DonutworkHelp Center
Api ReferenceRevenue & Partners
Donutwork documentation

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/json

The 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 requires charges:read.
  • CUSTOMER_ID must 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.json with taxes:read to select it; see tax profiles.
  • currency must 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.json

The body must contain only the charge object and must not exceed 64 KiB. Every field below is required.

FieldTypeContract
charge.sourcestringStable source-system name, up to 80 UTF-8 bytes. Keep the same value across retries.
charge.external_idstringStable transaction ID in that source, up to 200 UTF-8 bytes. Unique with source across the company, including across customers.
charge.created_atstringOriginal charge creation time in ISO 8601 with timezone, such as 2026-10-20T10:00:00Z.
charge.paid_atstringOriginal payment time in the same format; cannot precede created_at.
charge.currencystringThree-letter currency code matching the company currency.
charge.totalnumber or decimal stringPositive gross total, exactly equal to the sum of all line amounts and taxes.
charge.servicesarrayBetween 1 and 100 line objects.
charge.services[].namestringNon-empty line description, up to 250 UTF-8 bytes.
charge.services[].amountnumber or decimal stringPositive net amount, excluding tax.
charge.services[].taxAmountnumber or decimal stringTax already applied externally; zero is allowed.
charge.services[].taxProfilestringExisting 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 statusMeaning and action
200Existing import returned or an interrupted import completed; reuse the returned charge ID.
201New import completed.
400Missing or malformed JSON body.
401 / 403Authentication or authorization failed; check the key, company access, and charges:write.
404Customer not found in the authenticated company.
409Different content for the same source transaction, or another request is finalizing this import. Distinguish them by the error message.
413Body exceeds 64 KiB.
415Content type is not application/json.
422Invalid fields, dates, amounts, currency, tax profile, partner/group, or total. Correct the input before retrying.
429API rate limit reached; follow the response's retry guidance.
503Import 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

POST
/2026-02-01/customers/{customerId}/charges/import.json
Required permissioncharges:write

Path, query, and header parameters

customerIdstring· pathRequired
24-character hexadecimal customer ID from the authenticated company.

Request Body

JSON
{
  "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"
      }
    ]
  }
}
chargeobjectRequired
Required import envelope; only documented fields are accepted.
charge.sourcestringRequired
See the request contract above.
charge.external_idstringRequired
See the request contract above.
charge.created_atstringRequired
Original creation timestamp with timezone, not in the future.
charge.paid_atstringRequired
Original payment timestamp with timezone, at or after created_at.
charge.currencystringRequired
See the request contract above.
charge.totalnumber | stringRequired
Gross sum of net line amounts plus external taxes, in major currency units.
charge.servicesarrayRequired
1–100 rows with name, net amount, external taxAmount and company taxProfile.

Responses

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.

On this page