Skip to content

Never create a calendar event twice: idempotent writes on Google Calendar and Outlook

Updated

TL;DR

Duplicates come from retries: a create times out after the provider saved the event, and your retry saves it again. On Google, supply your own event id (base32hex, 5 to 1,024 characters) derived from your request, and a second insert fails with 409 duplicate. On Outlook, set transactionId on the create, though Microsoft doesn't document what a retry returns. Neither covers updates or deletes, so put an Idempotency-Key in front of every write.

How the duplicate happens

Your server sends a create to Google or Microsoft. The provider saves the event, and the response is lost: a timeout, a dropped connection, a worker restart. Your code, or your queue, retries. The provider saves it again. The person now has two identical meetings, and each attendee got two invitations.

Retrying is right. Retrying without a way to recognize the first attempt is the bug.

Google: choose the event ID yourself

Google lets you set the event's id on insert. The rules, from the events reference:

  • Characters are base32hex: "lowercase letters a-v and digits 0-9". No w to z, no uppercase, no dashes.
  • Between 5 and 1,024 characters, unique per calendar.
  • Google recommends "an established UUID algorithm such as one described in RFC4122", hex-encoded without dashes.

Derive the ID from something stable about the request, such as a hash of your own booking ID, and a retry sends the same ID. Google answers the second insert with 409, reason duplicate: "The requested identifier already exists." Treat that as success and read the event.

Two caveats from Google's own docs. It "cannot guarantee that ID collisions will be detected" at creation time, because the system is globally distributed, so a near-simultaneous double send can still slip through. And the Meet conferenceData.createRequest.requestId is a different thing: a fresh ID per request that controls whether a new meeting is created, not whether the event is.

Outlook: transactionId

Microsoft Graph has transactionId on events: "A custom identifier specified by a client app for the server to avoid redundant POST operations in case of client retries to create the same event." Set it on the create and keep it the same on retries. It can't be changed later.

What Microsoft doesn't document is what the retry returns, or for how long Graph remembers the ID. Plan for both outcomes: if the retry fails or returns something unexpected, look the event up before creating again.

Updates and deletes

Neither provider has an idempotency mechanism for updates or deletes. A repeated update is usually harmless if it sets the same values, but one that adds an attendee or appends to a description is not, and a repeated delete of an event that someone recreated in between is worse. Use a version check (If-Match with Google's etag or Graph's @odata.etag) so a retry of a write that already happened is refused rather than applied twice.

The Idempotency-Key pattern

The general answer, used by payment APIs and now an IETF draft (not yet an RFC), is a header the client sends with every write:

  1. The client generates a key for the operation and sends it with the first attempt and every retry.
  2. The server stores the key with a fingerprint of the request and, when it finishes, the response.
  3. A retry with the same key gets the stored response, without the work being done again.
  4. The same key with a different request is an error (the draft suggests 422), and a retry while the first attempt is still running is refused (409).

Implemented in front of the provider calls, it covers creates, updates and deletes alike, on every provider.

How Pyas does it

Every Pyas write takes an Idempotency-Key header, on every provider:

  • A retry with the same key returns the first response, marked with Idempotent-Replayed: true, for 24 hours. Nothing is created, changed or deleted twice.
  • The same key with a different request is refused with VALIDATION_IDEMPOTENCY_CONFLICT, and a retry while the first attempt is still running gets a conflict instead of running twice.
  • Failures you should retry aren't remembered. Rate limits and server errors release the key, so the next attempt runs for real.
  • The provider is protected too. On Google, Pyas derives the event's ID from your key, so even an attempt that reaches Google twice finds the event it already created. On Outlook, it sets transactionId from the key.
  • The SDK sends keys for you. @pyas/sdk generates a key for each write and reuses it across its own retries, or you pass yours with idempotencyKey.
book.tsTypeScript
const event = await pyas.events.create(
  { accountId, title: 'Intro call', startsAt, endsAt },
  { idempotencyKey: `booking-${bookingId}` },
)

Updates take a version check as well: send the event's version as If-Match, and a stale update is refused. See the calendar API for the rest.

Two-way calendar sync with Google Calendar and Outlook: the architecture, and the edge cases that break itGoogle Calendar, Outlook and Zoom APIs side by side