---
title: "Syncing data to external systems with events"
canonical: "https://help.twinfinity.com/space/HCFD/1643085835/Syncing%20data%20to%20external%20systems%20with%20events"
format: markdown
---
The most common integration is a one-way sync: *something changes in Twinfinity, and you push that change somewhere else.* Twinfinity gives you two pieces to build it — a stream of **events** delivered to your own Azure Service Bus, and an **OAuth client** your service can use to read more from the REST API. This page shows how to put them together.

```
Twinfinity ──(CloudEvent)──► Azure Service Bus ──(trigger)──► your function ──(REST + OAuth)──► your system
```

> **Prerequisite — provisioning.** Two things are set up per tenant before any of this runs, and both are arranged with Twinfinity / your administrator: (1) a *forwarding subscription* that pushes the events you care about to *your* Service Bus queue or topic, and (2) an *OAuth client* (client id + secret) with permission to read what you need. This page assumes both exist.

## 1. Receiving events

Twinfinity forwards events to an Azure Service Bus **queue or topic that you own**. You give Twinfinity the connection details when the subscription is provisioned; from then on, matching events arrive as messages. Anything that can read from Service Bus works — most often an **Azure Function** with a Service Bus trigger, but a service listening to the Service Bus works equally well.

### The event shape

Every message is JSON in the [CloudEvents 1.0](https://github.com/cloudevents/spec/blob/v1.0/spec.md) format. This is the contract you code against:

```json
{
  "specversion": "1.0",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "source": "/customers/<customerId>",
  "type": "com.twinfinity.forum-topic-upserted",
  "time": "2026-03-01T10:30:00Z",
  "subject": "forum~forum:1f0b9e2c-6a4d-4c7b-9d3e-2a5f8c1b0d64.topic:550e8400-e29b-41d4-a716-446655440000",
  "datacontenttype": "application/json",
  "data": { "title": "HVAC Review", "priority": "high" },
  "twinfinitysubscriptionid": "d4e5f6a7-...",
  "twinfinityrelatedentities": ["twin~twin:7c9e6679-7425-40de-944b-e07fc1f90ae7"]
}
```

The fields you will use most:

- `type` — what happened, e.g. `com.twinfinity.forum-topic-upserted`. Branch on this.
- `subject` — the entity that changed, as an **entity reference**: a domain, a `~`, then a `.`-joined
chain of `type:id` components written ancestor-first. `forum~forum:<forumId>.topic:<topicId>` names
a topic *and* the forum it lives in, because a topic id is only unique within its forum. Split the
domain off at the single `~`, then split the chain on `.`; each component's id is everything after
its first `:`. The ids you get out are exactly the ids the REST API takes — nothing is re-spelled.
- `data` — a compact, domain-specific payload. Enough to decide what to do; fetch the rest from the API when you need it.
- `id` — a stable event id. Also set as the Service Bus `MessageId`, so you get de-duplication and a natural idempotency key.
- `twinfinityrelatedentities` — other entities referenced by the change (e.g. the twin an issue is about).

### Event types available today

Event `type` follows the pattern `com.twinfinity.<domain>-<entityType>-<action>`. The set is growing; the types emitted today are:

| Event `type` | Raised when |
| --- | --- |
| `com.twinfinity.forum-topic-upserted`  
`com.twinfinity.forum-topic-deleted`  
`com.twinfinity.forum-topic-restored` | A forum topic is created or edited, removed, or restored. Creation and edit share one `upserted` action. |
| `com.twinfinity.forum-comment-upserted`  
`com.twinfinity.forum-comment-deleted`  
`com.twinfinity.forum-comment-restored` | A comment on a forum topic changes. |
| `com.twinfinity.twin-twin-upserted` | A twin's content is updated. |

Your forwarding subscription decides which of these reach you — it can filter by domain (e.g. all `forum` events), by entity type, by action (e.g. all `deleted` events), or take everything. You only receive the types you asked for, so write your handler to ignore anything it does not recognise.

### Delivery semantics

- **Near real-time.** Events are forwarded shortly after the change is committed.
- **At-least-once.** A given event may be delivered more than once. Make your handler **idempotent on **`id` (the Service Bus `MessageId` also lets the broker de-duplicate).
- **Security-trimmed.** A subscription runs as a specific Twinfinity identity, and only events that identity is allowed to see are delivered — so you never receive data the integration is not permitted to read.

## 2. Calling back into the API

Because `data` is deliberately small, most integrations take the id from the event's `subject` and call the REST API for the full record. Your service authenticates with the **OAuth client credentials** grant — a client id and secret, no interactive user — against your tenant's identity provider, and sends the resulting bearer token to the gateway.

### From Node / TypeScript with @twinfinity/authentication

The `@twinfinity/authentication` package implements the client-credentials flow and attaches the token to every request for you. It also refuses to run in a browser, since the secret must stay server-side — which is exactly what you want in a function or lambda.

```ts
import {
  TwinfinitySession,
  TwinfinityHttpClient,
  HttpMethod
} from '@twinfinity/authentication';

// Provider discovery, token acquisition and renewal are all handled for you.
const session = await TwinfinitySession.establishWithClientCredentials({
  apiUrl: process.env.TWINFINITY_API_URL!,        // https://<customer>.twinfinity.com
  clientId: process.env.TWINFINITY_CLIENT_ID!,
  clientSecret: process.env.TWINFINITY_CLIENT_SECRET!
});

const http = TwinfinityHttpClient.create(
  { clientName: 'my-integration', clientVersion: '1.0.0' },
  session
);

// http.fetch() now adds a fresh bearer token to every call.
const response = await http.fetch(HttpMethod.Get, someApiUrl);

// Or use one of the typed clients for simpler integration
const twinClient = new TwinClient(apiUrl, httpClient);
const twin = await twinClient.getTwin({ ... });
```

### Putting it together: an Azure Function

A Service Bus trigger gives you the CloudEvent directly. Build the auth client once per instance (tokens refresh on their own) and reuse it:

```ts
import { app, InvocationContext } from '@azure/functions';
import {
  TwinfinitySession,
  TwinfinityHttpClient,
  HttpMethod
} from '@twinfinity/authentication';

// Created lazily once, shared across invocations on this instance.
let clientPromise: Promise<TwinfinityHttpClient> | undefined;
function getClient(): Promise<TwinfinityHttpClient> {
  return (clientPromise ??= (async () => {
    const session = await TwinfinitySession.establishWithClientCredentials({
      apiUrl: process.env.TWINFINITY_API_URL!,
      clientId: process.env.TWINFINITY_CLIENT_ID!,
      clientSecret: process.env.TWINFINITY_CLIENT_SECRET!
    });
    return TwinfinityHttpClient.create(
      { clientName: 'issue-sync', clientVersion: '1.0.0' },
      session
    );
  })());
}

app.serviceBusQueue('issueSync', {
  connection: 'TWINFINITY_EVENTS',          // app setting: your Service Bus connection
  queueName: 'twinfinity-events',
  handler: async (message: unknown, _context: InvocationContext) => {
    const event = message as { type: string; subject: string; id: string };

    // Only act on the events this function cares about.
    if (!event.type.startsWith('com.twinfinity.forum-topic-')) return;

    // subject is an entity reference: "forum~forum:<forumId>.topic:<topicId>".
    // Read the ids component by component — do not split the whole string on ':',
    // which would break the moment a reference carries a path or an @version.
    // Both ids are needed: a topic id is unique only within its forum, and the
    // canonical route is /forums/{forumId}/topics/{topicId}.
    const ids = new Map(
      event.subject.split('~')[1].split('.').map((c) => c.split(':') as [string, string])
    );
    const forumId = ids.get('forum');
    const topicId = ids.get('topic');
    if (!forumId || !topicId) return;

    const http = await getClient();
    const res = await http.fetch(
      HttpMethod.Get,
      `${process.env.TWINFINITY_API_URL}/forums/${forumId}/topics/${topicId}`
    );

    const detail = await res.json();
    await syncToExternalSystem(detail);     // your code
  }
});
```

### From any other runtime

If your function is not Node, request a token directly from your tenant's token endpoint and send it as a bearer token. The grant is standard OAuth 2.0 client credentials:

```shell
curl -X POST "$TOKEN_ENDPOINT" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET"

# then call the API with: Authorization: Bearer <access_token>
```

The token endpoint for your tenant is provided together with the client id and secret at provisioning time. Tokens are short-lived — cache them and re-request shortly before they expire.

## What's available today

- **Delivery target:** Azure Service Bus.
- **Event format:** CloudEvents 1.0 JSON.
- **Events:** issue topics, issue comments, and twin content updates (see the table above) — with more domains added over time.
- **Auth for callbacks:** OAuth 2.0 client credentials, usable from `@twinfinity/authentication` or any HTTP client.

## In short

- Twinfinity forwards **CloudEvents** to **your Azure Service Bus**; your function or lambda consumes them.
- Branch on `type`, take the id from `subject`, and be **idempotent on **`id`.
- Fetch full detail from the REST API using an **OAuth client-credentials** token — easiest via `@twinfinity/authentication`.
- The Service Bus subscription and the OAuth client are **provisioned for you** — arrange them with your Twinfinity contact.