Data Management

Account Sources

Learn how to link and unlink external data sources to accounts via the API.

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: PublicApi when created through this API, Manual when typed in the web app, AdminApi or Mcp for 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 historical Api value. Read channel on the document, not source on the account.
  • A connector-imported document also carries a connectionId, naming the exact connection it came from. It is null on everything you create, and on documents that predate the field.
  • Never derive “can I write this” from channel or source. Every account, invoice and credit note carries readOnlyReason: null means it accepts writes, otherwise it names the connector that would overwrite them. It is returned by the single-resource endpoints and by every item of GET /accounts, GET /invoices and GET /credit-notes, so paging a list already tells you which rows you may update or delete. A write attempted while it is set fails with app-invoicing.document.managed-action-forbidden.
  • source: null alone does not mean “never connected”. An account also carries lastUnlinkedSource, the source it was detached from. Both null means the account was created by hand or through this API; source: null with lastUnlinkedSource set 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 Request error.

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.

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)

When importing data, link the source first, then create invoices and contacts:

  1. Create or update the account with source
  2. Create contacts
  3. 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 lastUpdate date 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

Support

Questions about account sources?
Contact us via the website contact form.