---
title: "Upsert Operations"
description: "Create or update resources in a single idempotent request using upsert endpoints."
canonical: https://developer.billabex.com/en/guides/upsert/
lang: en
alternate: https://developer.billabex.com/fr/guides/upsert/
last-updated: 2026-09-12
---

# Upsert Operations

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

Source: https://developer.billabex.com/en/guides/upsert/
Language: English (en)
French version: https://developer.billabex.com/fr/guides/upsert/

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:

| Endpoint                                    | Description      |
| ------------------------------------------- | ---------------- |
| `PUT /accounts/{accountId}/contacts/upsert` | Upsert a contact |

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 with the same email address. Email matching is **case-insensitive**.

```javascript
// 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.

```javascript
// 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` or `email`)

## Request Format

All upsert endpoints accept the same request body:

```json
{
  "fullName": "John Smith",
  "email": "john.smith@acme.com",
  "language": "en"
}
```

| Field      | Required  | Description                                |
| ---------- | --------- | ------------------------------------------ |
| `fullName` | See below | Person's full name                         |
| `email`    | See below | Email address (used for matching)          |
| `language` | No        | ISO 639-1 language code (e.g., `en`, `fr`) |

When **updating**, all fields are optional. Only provided fields will be modified. Sending
`fullName: null` on a contact this call matches leaves the existing name in place rather than
clearing it, so a nightly sync whose export has no name never wipes one: clearing a name goes
through the update endpoint.

When **creating**, one of `fullName` or `email` is required, not both: an address alone identifies a
contact and no name is derived from it. `language` is optional and defaults to the language of the
account's first enabled contact, then to the organization's default contact language, then to `fr`.

## Examples

### Upsert a Contact

```javascript
const response = await fetch(
  'https://next.billabex.com/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',
    }),
  },
);

const contact = await response.json();
```

### Using cURL

```bash
curl -X PUT "https://next.billabex.com/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"
  }'
```

## Response

All upsert endpoints return the created or updated record:

```json
{
  "id": "789e0123-e89b-12d3-a456-426614174000",
  "fullName": "John Smith",
  "email": {
    "address": "john.smith@acme.com",
    "status": "Valid"
  },
  "language": "en"
}
```

## Create vs Update Behavior

| Scenario                                       | Result             |
| ---------------------------------------------- | ------------------ |
| Email matches existing record                  | Update that record |
| Name matches existing record (no email match)  | Update that record |
| No match found, `fullName` or `email` provided | Create new record  |
| No match found, neither `fullName` nor `email` | Error 400          |

## Use Cases

### Periodic Sync from ERP

Synchronize customer contacts from your ERP system daily:

```javascript
for (const erpContact of erpContacts) {
  await fetch(
    `https://next.billabex.com/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',
      }),
    },
  );
}
```

### Webhook Handler

Handle incoming webhooks without worrying about record state:

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

  // Upsert handles both new and existing contacts
  await fetch(`https://next.billabex.com/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

- **[Contacts](/en/guides/contacts/)** : Learn more about managing people
- **[Account Sources](/en/guides/sources/)** : Link accounts to external systems
- **[API Reference](/en/api-reference/)** : Full endpoint documentation

## Support

Questions about upsert operations?  
Contact us via the [website contact form](https://billabex.com).

---

Billabex developer portal. OpenAPI specification: https://developer.billabex.com/openapi.json.
Agent instructions: https://developer.billabex.com/llms.txt. Complete documentation: https://developer.billabex.com/llms-full.txt.
