Idempotency & Concurrency Guide

Safely retry requests and handle concurrent updates.

When syncing your CRM with Propstreet, you need to handle network failures and concurrent edits gracefully. This guide shows you how.

ETags for Optimistic Concurrency

ETags prevent "lost update" problems when multiple systems modify the same contact.

How It Works

  1. GET returns an ETag header with a version identifier
  2. PUT/PATCH includes If-Match header with that ETag
  3. If the contact changed since you fetched it and your If-Match is stale, you get 412 Precondition Failed
  4. Refetch, merge changes, and retry

Example

# 1. Fetch the contact
curl "https://app.propstreet.com/api/v1/network/contacts/123" \
  -H "Authorization: Bearer $TOKEN"

Response:

ETag: W/"abc123def456"
Content-Type: application/json

{
  "id": 123,
  "firstName": "Jane",
  "lastName": "Doe"
}
# 2. Update with If-Match
curl -X PUT "https://app.propstreet.com/api/v1/network/contacts/123" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H 'If-Match: W/"abc123def456"' \
  -d '{"firstName": "Janet", "lastName": "Doe"}'

Handling 412 Precondition Failed

If someone else updated the contact:

{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Precondition failed",
  "status": 412,
  "detail": "ETag mismatch - resource was modified"
}

Resolution:

  1. Refetch the contact to get the latest version
  2. Merge your changes with the current state
  3. Retry the update with the new ETag

Idempotency-Key for Creates

Send an Idempotency-Key header (a UUID v4) on a POST that creates something, so a retry after a timeout cannot create it twice:

curl -X POST "https://app.propstreet.com/api/v1/comps" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: 6f1c2b7e-8a4d-4c3e-9f20-1b5d7e9a0c11" \
  -H "Content-Type: application/json" \
  -d @deal.json
  • The key is kept for 24 hours. A retry with the same key and the same body answers the first response again (same status and body) without writing anything.
  • The same key with a different body — or, on a child route, for another parent — is refused with 422 (/help/idempotency-conflict).
  • When the first request may or may not have been saved, a retry answers 409 (/help/idempotency-outcome-unknown): check the resource, then retry with a new key.
  • The routes that take a key include the single creates (POST on contacts, companies, links, mandates, projects, prospects and tasks), the batch write routes documented as keyed in their own guides (for example prospects:batchUpdate and prospects/tasks:batch), task move / replace, and on comps: POST /api/v1/comps, POST /api/v1/comps/{id}/parties, .../sources and .../notes (the key covers the deal and the body), and POST /api/v1/comps:merge.
  • Not every batch route replays: POST /api/v1/projects/{id}/prospects:batch ignores the key, and a retry runs the batch again.
  • PATCH and DELETE ignore the header: use If-Match for PATCH, and treat a second 404 on DELETE as done.

External References for Idempotent Creates

Use externalRefs to make create operations safe to retry:

curl -X POST "https://app.propstreet.com/api/v1/network/contacts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Jane",
    "lastName": "Doe",
    "externalRefs": [
      {"namespace": "my-crm", "id": "contact-12345"}
    ]
  }'

If you retry and the contact already exists with that external ID, you get the existing contact instead of a duplicate.

Safe Retry Patterns

Creating Contacts

async function createContactSafely(contact) {
  const payload = {
    ...contact,
    externalRefs: [{ namespace: "my-crm", id: contact.crmId }],
  };

  try {
    return await createContact(payload);
  } catch (error) {
    if (error.status === 409) {
      // Already exists—fetch and return it
      return await getContactByExternalRef("my-crm", contact.crmId);
    }
    throw error;
  }
}

Updating Contacts

async function updateContactSafely(id, changes, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const { data, etag } = await getContact(id);
    const updated = { ...data, ...changes };

    try {
      return await updateContact(id, updated, etag);
    } catch (error) {
      if (error.status === 412 && attempt < maxRetries - 1) {
        continue; // Retry with fresh data
      }
      throw error;
    }
  }
  throw new Error("Max retries exceeded");
}

Batch Operations

For efficient sync, use batch endpoints:

curl -X POST "https://app.propstreet.com/api/v1/network/contacts/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"ids": [123, 456, 789]}'

Single request, multiple contacts.

Best Practices

  1. Always use ETags for updates—prevents overwriting changes
  2. Include external refs on create—makes creates idempotent
  3. Implement retry with backoff—handles transient failures
  4. Use batch operations—reduces request count

Conflict Resolution Strategies

Strategy When to Use
Last Write Wins Simple integrations
Merge Different fields updated
Timestamp-Based Bidirectional sync with clear "latest wins"
Manual Review Critical data requiring human decision

For bidirectional sync, compare updatedUtc:

  • If Propstreet is newer → apply to your CRM
  • If your CRM is newer → push to Propstreet (with ETag)