TLDR: When you hit a ContactId cannot be null or empty 400 from Xero (the accounting platform), the tempting fix is blind-create. Don't. Find-or-create the contact first — then back the whole thing with a deterministic idempotency key. Two different tools, same goal: never make a duplicate.

The Setup

I've been building a custom practice management system for my business partner at a law firm client, a solo IP and trademark firm.

When a matter goes Active, the system auto-creates a Xero project so her time billing stays wired without any manual touch.

Sounds clean. Was not.

The Wall

The Xero Projects API requires a ContactId — a Xero-internal UUID — to create any project.

No UUID, no project. And that's exactly what we hit:

ContactId cannot be null or empty

  1. Dead stop.

What I Tried First (and Killed)

The obvious move: just blind-create a new Xero contact for every matter that activates. Grab the fresh UUID, pass it to Projects, done.

Except — my business partner's Xero already has real contacts. Years of billing history. Blind-creating would spawn a duplicate client right next to the original. She'd already accumulated test-contact junk from our early development. Duplicates in an accounting system aren't just messy. They're genuinely dangerous.

That approach died in about ten minutes.

The Fix That Worked

Find-or-create.

Before touching the Projects API, the system now hits accounting.contacts (an OAuth scope I had to add explicitly — easy to miss) and searches by the client's name and email. If a match comes back, we use that UUID. If nothing matches, we create the contact then and only then.

I said the same thing while debugging a different Xero integration with a developer I work with a few weeks ago: "we search by the contact's name or their email… we're trying to find a contact ID." Same pattern, every time. Search first. Create on miss. Never assume.

On top of that, every project creation now goes out with a deterministic idempotency key: matter-{id}-xero-project. That's a different tool for a different problem — not "find what already exists" but "if you get this request twice, only create it once." Background retries, network hiccups, my business partner clicking the button twice — the key absorbs all of it.

The Two Shapes (This Is the Real Lesson)

I got these confused at first, and I think a lot of people do:

  • Find-or-create — the external system owns the ID and you only have fuzzy attributes (a name, an email). You search, you match, you fall back to create.
  • Deterministic idempotency keyyou control a stable natural key. You compute it once, pass it every time, and the API deduplicates for you.

Same goal. Completely different mechanics depending on who owns the identity.

The Open Edge Case

I'm still thinking through this one: what happens if a name search returns TWO Xero contacts with the same name?

Real scenario for a law firm — two different clients with the same company name across separate matters. The current code picks the first match, which is probably fine. But my business partner and I haven't defined the tiebreak rule yet, and I don't pretend we have.

That's a conversation still ahead of us.

What I do know: search before you create, key before you retry is now in every Xero integration I build. Nail those two shapes and you avoid a whole category of integration failures that are miserable to clean up after the fact.

P.S. The accounting.contacts OAuth scope is separate from projects. I missed it on the first pass. If you're hitting contact-related 400s on the Xero Projects API, check your scopes first — it's the lowest-effort fix.