Data Management

Upsert Operations

Create or update resources in a single idempotent request using upsert endpoints.

Upsert Operations

When synchronizing data from external systems, you often need to create new records or update existing ones. The upsert pattern combines both operations into a single, idempotent request. This guide explains how upsert endpoints work in the Billabex API.

What is Upsert?

Upsert is a portmanteau of “update” and “insert”. An upsert operation:

  1. Checks if a matching record already exists
  2. Updates the record if found
  3. Creates a new record if not found

This eliminates the need to first check if a record exists before deciding whether to create or update it.

Why Use Upsert?

Upsert operations offer several advantages:

  • Idempotence – Running the same request multiple times produces the same result
  • Simplicity – One endpoint instead of separate create and update logic
  • Reliability – No race conditions between “check” and “create” operations
  • Sync-friendly – Perfect for periodic data synchronization

Available Upsert Endpoints

Billabex provides upsert endpoints for managing people associated with accounts:

EndpointDescription
PUT /accounts/{accountId}/contacts/upsertUpsert a contact
PUT /accounts/{accountId}/internal-representatives/upsertUpsert an internal representative
PUT /accounts/{accountId}/party/upsertUpsert with auto-detection

All endpoints require the accounts:all OAuth scope.

How Matching Works

The upsert endpoints use a two-step matching algorithm to find existing records:

Step 1: Email Match (Priority)

If an email is provided in the request, the system first looks for an existing contact or internal representative with the same email address. Email matching is case-insensitive.

// This will match an existing contact with email "john@acme.com"
{
  "email": "John@ACME.com",
  "fullName": "John Smith"
}

Step 2: Name Match (Fallback)

If no email match is found (or no email was provided), the system looks for a name match using fuzzy matching based on the Levenshtein distance algorithm.

A name is considered a match if the edit distance is:

  • At most 2 characters, or
  • At most 20% of the shorter name’s length

Whichever is smaller.

// These names would match "John Smith":
'john smith'; // Case difference only
'John Smth'; // 1 character missing
'Jon Smith'; // 1 character different

// These would NOT match:
'Jonathan Smith'; // Too many extra characters
'J. Smith'; // Too different

Match Result

  • If a match is found → The existing record is updated with the provided fields
  • If no match is found → A new record is created (requires fullName and language)

Request Format

All upsert endpoints accept the same request body:

{
  "fullName": "John Smith",
  "email": "john.smith@acme.com",
  "language": "en",
  "role": "Billing Manager",
  "notes": "Primary contact for invoices",
  "isPrimary": true
}
FieldRequiredDescription
fullNameFor createPerson’s full name
emailNoEmail address (used for matching)
languageFor createISO 639-1 language code (e.g., en, fr)
roleNoJob title or role
notesNoAdditional notes
isPrimaryNoWhether this is the primary contact

When updating, all fields are optional. Only provided fields will be modified.

When creating, fullName and language are required.

Examples

Upsert a Contact

const response = await fetch('[baseURL]/api/public/v1/accounts/ACCOUNT_ID/contacts/upsert', {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    fullName: 'John Smith',
    email: 'john.smith@acme.com',
    language: 'en',
    role: 'Billing Manager',
    isPrimary: true,
  }),
});

const contact = await response.json();

Using cURL

curl -X PUT "[baseURL]/api/public/v1/accounts/ACCOUNT_ID/contacts/upsert" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fullName": "John Smith",
    "email": "john.smith@acme.com",
    "language": "en",
    "role": "Billing Manager",
    "isPrimary": true
  }'

Upsert an Internal Representative

const response = await fetch('[baseURL]/api/public/v1/accounts/ACCOUNT_ID/internal-representatives/upsert', {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    fullName: 'Sarah Johnson',
    email: 'sarah@yourcompany.com',
    language: 'en',
    role: 'Account Manager',
  }),
});

Upsert Party (Auto-Detection)

The party endpoint automatically determines whether to create a contact or internal representative based on the email domain:

const response = await fetch('[baseURL]/api/public/v1/accounts/ACCOUNT_ID/party/upsert', {
  method: 'PUT',
  headers: {
    Authorization: `Bearer ${accessToken}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    fullName: 'Alex Brown',
    email: 'alex@example.com',
    language: 'en',
  }),
});

If alex@example.com’s domain matches your organization’s email domain, they become an internal representative. Otherwise, they become a contact.

Response

All upsert endpoints return the created or updated record:

{
  "id": "789e0123-e89b-12d3-a456-426614174000",
  "fullName": "John Smith",
  "email": {
    "address": "john.smith@acme.com",
    "status": "Valid"
  },
  "language": "en",
  "role": "Billing Manager",
  "notes": null,
  "isPrimary": true
}

Create vs Update Behavior

ScenarioResult
Email matches existing recordUpdate that record
Name matches existing record (no email match)Update that record
No match found, fullName + language providedCreate new record
No match found, missing fullName or languageError 400

Use Cases

Periodic Sync from ERP

Synchronize customer contacts from your ERP system daily:

for (const erpContact of erpContacts) {
  await fetch(`[baseURL]/api/public/v1/accounts/${erpContact.accountId}/contacts/upsert`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      fullName: erpContact.name,
      email: erpContact.email,
      language: erpContact.language || 'en',
      role: erpContact.jobTitle,
    }),
  });
}

Webhook Handler

Handle incoming webhooks without worrying about record state:

app.post('/webhook/contact-updated', async (req, res) => {
  const { accountId, contact } = req.body;

  // Upsert handles both new and existing contacts
  await fetch(`[baseURL]/api/public/v1/accounts/${accountId}/contacts/upsert`, {
    method: 'PUT',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(contact),
  });

  res.sendStatus(200);
});

Next Steps

Support

Questions about upsert operations?
Contact us via the website contact form.