Batches and asynchronous resultsMenu
On this page

Batches and asynchronous results

Updated

Send up to 100 contacts, events or messages in one call, read each item's result, retry safely, and know that accepted is not delivered.

Three operations take up to 100 items in one request: identifyContactsBatch (POST /v1/contacts/batch), trackEventsBatch (POST /v1/events/batch) and sendTransactionalMessagesBatch (POST /v1/transactional/messages/batch). A batch saves round trips. It does not save rate budget, and a successful status does not mean every item succeeded: you read the result of each item.

One result per item, in order

The response holds results, one entry for each item you sent, in the same order. An entry holds either what was written or an error: the same code, message and details a single call would have returned. One refused item does not stop the others.

JSON
{
  "data": {
    "results": [
      { "created": true, "contact": { "id": "0192f3a1-…", "externalId": "customer-881" } },
      {
        "error": {
          "code": "CONFLICT",
          "message": "The email belongs to a contact with a different externalId; …",
          "details": { "field": "externalId" }
        }
      }
    ]
  }
}

The position in results is the position in your request, so results[1] is the second item you sent. Keep your items in an array until you have read the results.

Operation Status A written item holds Item key
identifyContactsBatch 200 created and the contact None: identify converges, so a repeat sets the same values
trackEventsBatch 200 idempotencyKey, replayed and the event idempotencyKey, optional; without one a retry records the event again
sendTransactionalMessagesBatch 202 idempotencyKey, replayed, the messages (one per channel stored) and skippedChannels idempotencyKey, optional; without one a retry sends again

What is checked before any item runs

The batch’s own shape is checked first: at least 1 and at most 100 items, and no idempotencyKey used twice in the call. A batch that fails it is refused whole with 400 VALIDATION_ERROR, and nothing runs.

After that, each item is checked on its own, in all three. An item that fails validation, such as a contact with a malformed email, is that item’s VALIDATION_ERROR, with its issues in errors, and the rest still run. A nightly sync of 1,000 customers in ten batches writes the 999 valid ones and reports the one to fix. An item refused for what is stored, such as an identity conflict, is likewise that item’s error only.

Credential checks apply to the whole request: a key without the scope, or an agent key, refuses the batch with 403. The plan’s event allowance is item by item: once it is used up, the remaining events are each refused with PLAN_LIMIT_REACHED.

Rate limits count each item

A batch counts against the rate limit as one request per item, once its shape is accepted. An item that is then refused still counts. Contacts and transactional sends draw on the high-volume budget, events on the events budget.

Retrying a batch

After a timeout or a 5xx, send the same batch again with the same keys:

  • An event or a transactional message already stored returns with replayed: true, and nothing is recorded or sent twice.
  • An event or transactional message sent without idempotencyKey is recorded or sent again. Give every entry a key derived from what it is, such as order-<order id>-completed or receipt-<order id>.
  • An identify sets the same values again, which is safe.

Unlike other POSTs, a batch takes no Idempotency-Key header: the per-item keys are what protect it. They recognise a send that first went out on its own, and they last as long as what they created, so retry a failed batch whole with the same item keys. See Idempotency.

To retry only the failures, resend the items whose entries carry an error you can fix, with the same keys. Do not resend an item that failed with a 4xx unchanged: it fails the same way.

Example: collect the failures

This sends one batch of contacts and returns the indexes and codes of those refused:

TypeScript
type BatchResult = { error?: { code: string; message: string } };

export async function identifyContacts(contacts: object[]) {
  const response = await fetch('https://api.kirora.io/v1/contacts/batch', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.KIRORA_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ contacts }),
  });
  if (!response.ok) throw new Error(`Batch refused with ${response.status}`);

  const { data } = (await response.json()) as { data: { results: BatchResult[] } };
  const failed = data.results.flatMap((result, index) =>
    result.error ? [{ index, code: result.error.code, contact: contacts[index] }] : [],
  );
  return { written: data.results.length - failed.length, failed };
}

Log failed with your own record ids, not the whole request: it may hold personal data.

Accepted is not delivered

A 2xx from a write means Kirora accepted and stored the work. What follows happens later:

  • A transactional send returns 202 with each message queued. It is then sent, delivered, bounced, skipped or failed. Follow it with the message.* webhooks, or read it with getMessage.
  • A tracked event is stored when the call returns. The journeys it starts and the conversions it counts run afterwards.
  • A campaign send returns 202 with the campaign; each recipient’s message follows as its own row in the message log.

Do not poll in a tight loop for an outcome. Subscribe to webhooks, or read the message once, some time later.

Next steps