Account Sources
When synchronizing accounts from an external system (ERP, CRM, billing platform), you can link each Billabex account to its corresponding record in your system using a source reference. This guide explains how sources work and how to manage them via the API.
What is a Source?
A source is a reference that links a Billabex account to an external system. It tells Billabex:
- Where the account data comes from (your integration identifier)
- Which record it maps to (the external system ID)
- When it was last synchronized
When an account has a source, Billabex treats it as externally managed. This affects how the account can be modified and deleted.
An account source and a document sourceId are two different things
An organization has no single data source. It can hold several connector connections at once (Pennylane, Qonto, Sellsy, Stripe, Zoho Books) while its team keys invoices into the web app and your integration posts others through this API.
The source described on this page belongs to an account. Invoices and credit notes do not carry a source reference, but each one can carry a flat sourceId: your identifier for that document in your system. Same word, two namespaces, two scopes:
| Field | Lives on | Scope of uniqueness | Set by |
|---|---|---|---|
source.sourceId |
an account | per connection | PUT /accounts/{accountId}/source |
sourceId |
an invoice/credit note | per organization | POST /invoices, POST /credit-notes |
Send sourceId when you create a document and you get an idempotency handle for free: a second create with the same sourceId is rejected with app-invoicing.invoice.duplicate-source-id (or app-invoicing.credit-note.duplicate-source-id) as a 400, instead of silently producing a duplicate after a lost HTTP response. Recover the document with GET /invoices?filters=sourceId:is:YOUR-ID. The value is frozen at creation, never editable afterwards, opaque to Billabex, and case-sensitive: sourceId only accepts the is, isAnyOf, isEmpty and isNotEmpty filter operators, never contains or equals.
Document numbers are unique per account, not per organization: two of your customers may each have their own AVKA0011527, and both are accepted. A lookup by number scoped to an account answers that account’s document; a lookup by number across a whole organization may answer any one of the namesakes, so pass the account whenever you know it. Reconciling on sourceId avoids the question entirely.
Consequences worth internalizing:
- An account without a source is perfectly normal. It simply means no connector drives it. It is not an incomplete or broken record.
- The source of an account never tells you where one of its invoices came from. Every invoice and credit note carries its own
channel, set once at creation and never changed afterwards:PublicApiwhen created through this API,Manualwhen typed in the web app,AdminApiorMcpfor the other programmatic entry points, or the connector name when imported by a connector. Documents created before these entry points were told apart carry the historicalApivalue. Readchannelon the document, notsourceon the account. - A connector-imported document also carries a
connectionId, naming the exact connection it came from. It isnullon everything you create, and on documents that predate the field. - Never derive “can I write this” from
channelorsource. Every account, invoice and credit note carriesreadOnlyReason:nullmeans it accepts writes, otherwise it names the connector that would overwrite them. It is returned by the single-resource endpoints and by every item ofGET /accounts,GET /invoicesandGET /credit-notes, so paging a list already tells you which rows you may update or delete. A write attempted while it is set fails withapp-invoicing.document.managed-action-forbidden. source: nullalone does not mean “never connected”. An account also carrieslastUnlinkedSource, the source it was detached from. Bothnullmeans the account was created by hand or through this API;source: nullwithlastUnlinkedSourceset means the link to a billing tool was broken, so the figures no longer follow that tool. Read the two together before treating an account as manually managed.
Source Properties
A source reference contains four properties:
| Property | Type | Description |
|---|---|---|
connectionId |
string | Identifier for your integration (e.g., my-erp-sync) |
sourceId |
string | The account’s ID in your external system (e.g., CUST-00123) |
connectorType |
string | Always Custom for API-managed sources |
lastUpdate |
datetime | When the source was last linked or updated |
The connectionId groups accounts from the same integration. Use a consistent value across all accounts from the same source system.
The sourceId uniquely identifies the account in your external system. This should match the primary key or unique identifier in your source database.
Linking a Source
Use the PUT endpoint to link a source to an existing account:
PUT /api/public/v1/accounts/{accountId}/source
This endpoint is idempotent: if you call it with the same source (matching connectionId and sourceId), it will simply update the lastUpdate date instead of returning an error.
Request
const response = await fetch(
'[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source',
{
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
connectionId: 'my-erp-integration',
sourceId: 'CUST-00123',
// Optional: specify a custom lastUpdate date
// lastUpdate: '2024-01-15T10:30:00.000Z',
}),
},
);
const account = await response.json();
Using cURL
curl -X PUT "[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"connectionId": "my-erp-integration",
"sourceId": "CUST-00123"
}'
Request Body
| Property | Type | Required | Description |
|---|---|---|---|
connectionId |
string | Yes | Identifier for your integration |
sourceId |
string | Yes | The account’s ID in your external system |
lastUpdate |
datetime | No | When the source was last synchronized (defaults to now) |
Response
The endpoint returns the updated account with the source reference:
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"fullName": "Acme Corporation",
"source": {
"connectionId": "my-erp-integration",
"sourceId": "CUST-00123",
"connectorType": "Custom",
"lastUpdate": "2024-01-15T10:30:00.000Z"
}
}
Note that connectorType is always set to Custom. If lastUpdate is not provided, it defaults to the current server time.
Updating the Synchronization Date
Use the PATCH endpoint to update only the lastUpdate date of an existing source:
PATCH /api/public/v1/accounts/{accountId}/source
This is useful when you want to mark an account as “recently synchronized” without re-linking the entire source.
Request
const response = await fetch(
'[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source',
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
// Optional: specify a custom lastUpdate date
// If omitted, defaults to the current time
lastUpdate: '2024-01-15T10:30:00.000Z',
}),
},
);
const account = await response.json();
Using cURL
# Update with current timestamp
curl -X PATCH "[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
# Update with specific timestamp
curl -X PATCH "[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"lastUpdate": "2024-01-15T10:30:00.000Z"
}'
Request Body
| Property | Type | Required | Description |
|---|---|---|---|
lastUpdate |
datetime | No | The new synchronization date (defaults to current time) |
Response
The endpoint returns the updated account:
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"fullName": "Acme Corporation",
"source": {
"connectionId": "my-erp-integration",
"sourceId": "CUST-00123",
"connectorType": "Custom",
"lastUpdate": "2024-01-15T10:30:00.000Z"
}
}
Note: This endpoint only works for accounts that already have a source linked. If the account has no source, you’ll receive a
400 Bad Requesterror.
Creating an Account with Source
You can also include the source when creating a new account:
const response = await fetch('[baseURL]/api/public/v1/accounts', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
organizationId: 'YOUR_ORG_ID',
fullName: 'Acme Corporation',
currencyCode: 'EUR',
billingAddress: {
street: '123 Main Street',
city: 'Paris',
postalCode: '75001',
country: 'FR',
},
source: {
connectionId: 'my-erp-integration',
sourceId: 'CUST-00123',
},
}),
});
This creates the account and links the source in a single request.
Unlinking a Source
To remove the source link from an account, use the DELETE endpoint:
DELETE /api/public/v1/accounts/{accountId}/source
Request
const response = await fetch(
'[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source',
{
method: 'DELETE',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
// Returns 204 No Content on success
Using cURL
curl -X DELETE "[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
The endpoint returns 204 No Content on success.
Implications in Billabex
Linking a source to an account has several important implications:
1. Deletion Protection
Accounts with a source cannot be deleted through the Billabex UI. Users must first unlink the source before the account can be removed. This prevents accidental deletion of synchronized data.
2. UI Exclusion Disabled
Accounts with a Custom source cannot be excluded (blacklisted) through the Billabex UI. The exclusion feature is designed for connector-managed accounts (like Pennylane, Qonto, Sellsy, Stripe, or Zoho Books) where users might want to filter which accounts are synced.
For API-managed accounts, you control which accounts exist in Billabex directly through your integration.
3. Orphan Status After Unlink
When you unlink a source, the account becomes an “orphan” - it no longer has a connection to any external system. The account remains in Billabex and can then be:
- Deleted manually through the UI
- Re-linked to a different source via the API
- Managed independently
4. Only Custom Sources Can Be Unlinked via API
The unlink endpoint only works for accounts with a Custom source. Accounts linked through Billabex connectors (Pennylane, Qonto, Sellsy, Stripe, Zoho Books) must be managed through their respective connection settings.
Best Practices
Use Consistent Connection IDs
Choose a meaningful connectionId that identifies your integration:
// Good - identifies the integration clearly
connectionId: 'salesforce-billing-sync';
connectionId: 'netsuite-production';
// Avoid - too generic or inconsistent
connectionId: 'sync';
connectionId: 'integration-1';
Use Stable Source IDs
The sourceId should be the primary key or stable identifier from your system:
// Good - stable identifiers
sourceId: 'CUST-00123'; // Customer number
sourceId: 'acc_1234567890'; // Database ID
// Avoid - values that might change
sourceId: 'acme-corp'; // Company name (can change)
Link Before Creating Related Data
When importing data, link the source first, then create invoices and contacts:
- Create or update the account with source
- Create contacts
- Create invoices referencing the account
This ensures all data is properly associated with the external system reference.
Handle Re-linking Gracefully
The link endpoint (PUT) is idempotent. If you call it with the same connectionId and sourceId:
- The
lastUpdatedate will be updated - No error will be returned
If you need to change the source to a different external record, you must first unlink the current source, then link the new one:
// 1. Unlink current source
await fetch('[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source', {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
});
// 2. Link new source
await fetch('[baseURL]/api/public/v1/accounts/ACCOUNT_ID/source', {
method: 'PUT',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
connectionId: 'new-erp-integration',
sourceId: 'NEW-CUST-456',
}),
});
Next Steps
- Upsert Operations – Create or update contacts in a single request
- Contacts – Manage people associated with accounts
- Getting Started – API basics and authentication
- API Reference – Full endpoint documentation
Support
Questions about account sources?
Contact us via the website contact form.