When you provide a Forge Teamwork Graph connector and a customer sets up your connector, Atlassian ingests connected third-party data into Teamwork Graph on behalf of the customer.
For a step-by-step tutorial on building a connector, see Build a Teamwork Graph connector.
Follow these development practices to avoid common issues in Forge Teamwork Graph connectors.
Do not treat ingestion calls as fire-and-forget. Always inspect the response from setObjects, setUsers, or setGroups. A call can fail completely or partially reject individual items.
Good:
1 2async function ingestObjects(connectionId, objects) { const response = await graph.setObjects({ connectionId, objects, }); const rejected = response.results?.rejected ?? []; if (!response.success || rejected.length > 0) { console.error('Teamwork Graph object ingestion failed', { connectionId, acceptedCount: response.results?.accepted?.length ?? 0, rejectedCount: rejected.length, rejected, error: response.error, }); return { success: false, message: 'Some objects were rejected during ingestion. Check app logs for details.', }; } console.log('Teamwork Graph object ingestion succeeded', { connectionId, objectCount: objects.length, }); return { success: true }; }
Bad:
1 2async function ingestObjects(connectionId, objects) { await graph.setObjects({ connectionId, objects, }); return { success: true }; }
The bad pattern hides rejected objects and makes the connection look healthy even when ingestion failed.
If your objects use USER or GROUP principals in permissions, ingest the corresponding users or groups and use the exact same external IDs in access controls. Mismatched IDs can make objects invisible to users who should have access.
Good:
1 2async function ingestRestrictedDocument(connectionId) { const userExternalId = 'user-42'; const userResponse = await graph.setUsers({ connectionId, users: [ { externalId: userExternalId, displayName: 'Alex Chen', emails: [{ value: 'alex@example.com', primary: true }], }, ], }); if (!userResponse.success || userResponse.results?.failures?.length) { throw new Error(userResponse.results?.failures?.map(f => f.error).join('; ') ?? userResponse.error ?? 'User ingestion failed'); } const objectResponse = await graph.setObjects({ connectionId, objects: [ { schemaVersion: '1.0', id: 'doc-123', updateSequenceNumber: 1, displayName: 'Quarterly planning notes', url: 'https://example.com/docs/doc-123', createdAt: '2026-08-05T00:00:00.000Z', lastUpdatedAt: '2026-08-05T00:00:00.000Z', permissions: [ { accessControls: [ { principals: [{ type: 'USER', id: userExternalId }], }, ], }, ], 'atlassian:document': { type: { category: 'document' }, content: { mimeType: 'text/plain', text: 'Quarterly planning notes' }, }, }, ], }); const rejected = objectResponse.results?.rejected ?? []; if (!objectResponse.success || rejected.length > 0) { throw new Error(objectResponse.error ?? 'Object ingestion failed'); } return { success: true }; }
Bad:
1 2async function ingestRestrictedDocument(connectionId) { const ingestedUserId = 'user-42'; const accessControlUserId = 'accountId:user-42'; const userResponse = await graph.setUsers({ connectionId, users: [ { externalId: ingestedUserId, displayName: 'Alex Chen', emails: [{ value: 'alex@example.com', primary: true }], }, ], }); if (!userResponse.success || userResponse.results?.failures?.length) { throw new Error(userResponse.error ?? 'User ingestion failed'); } const objectResponse = await graph.setObjects({ connectionId, objects: [ { schemaVersion: '1.0', id: 'doc-123', updateSequenceNumber: 1, displayName: 'Quarterly planning notes', url: 'https://example.com/docs/doc-123', createdAt: '2026-08-05T00:00:00.000Z', lastUpdatedAt: '2026-08-05T00:00:00.000Z', permissions: [ { accessControls: [ { principals: [{ type: 'USER', id: accessControlUserId }], }, ], }, ], 'atlassian:document': { type: { category: 'document' }, content: { mimeType: 'text/plain', text: 'Quarterly planning notes' }, }, }, ], }); const rejected = objectResponse.results?.rejected ?? []; if (!objectResponse.success || rejected.length > 0) { throw new Error(objectResponse.error ?? 'Object ingestion failed'); } return { success: true }; }
The bad pattern ingests the user as user-42 but references accountId:user-42 in object permissions. Those IDs do not match.
Add an optional capabilities block to your connector module in manifest.yml. These values are displayed to customer admins on the configuration screen before they enable your connector.
1 2modules: graph:connector: - key: my-connector name: My Connector capabilities: replicatesPermissions: true syncFidelity: mirror supportsIncrementalSync: true icons: light: resource:icon-light dark: resource:icon-dark objectTypes: - atlassian:document datasource: # ... your datasource configuration
| Property | Type | Allowed values | Default (if omitted) | Description |
|---|---|---|---|---|
replicatesPermissions | boolean | true, false | omitted | Whether the connector mirrors source-system access controls into Teamwork Graph. If false or omitted, all ingested data is visible to every user in the Atlassian workspace. |
syncFidelity | enum | append, upsert, mirror | omitted | append = new objects only. upsert = new objects and updates to existing objects. mirror = new objects, updates, and deletes so Teamwork Graph always reflects current source state. mirror is recommended. |
supportsIncrementalSync | boolean | true, false | omitted | Whether the connector supports delta sync (only changes since the last run). If false or omitted, each sync is a full re-ingestion. |
The entire capabilities block is optional. If omitted, the admin configuration screen displays "Capabilities not declared by developer," which may reduce customer confidence. Only the fields listed above are accepted.
For the full manifest reference for this module, see Teamwork Graph connector.
Your connector must be accompanied by developer documentation covering all of the following:
Link your documentation from your Marketplace listing and keep it current as your connector evolves. This documentation is referenced in the admin consent screen.
Your connector must comply with Teamwork Graph data policies. Do not ingest data types outside the supported object type set. For the full list of restrictions and compliance requirements, see Limitations and considerations.
| Approach | Behavior | Developer requirement |
|---|---|---|
| Mirror (recommended) | Sync deletions from the source so Teamwork Graph always reflects current source state. | Implement delete propagation. No additional documentation required beyond standard sync docs. |
| Disconnect-only | Deletions are not synced. All ingested data persists in Teamwork Graph until the admin disconnects the connector. | Document this limitation prominently in your connector docs and Marketplace listing. State that admins must disconnect to remove stale data. |
If your connector does not sync deletions, you must clearly state this in your developer documentation. Customers with strict data-retention policies may not enable connectors that lack deletion support.
When an admin disconnects a connector instance, Atlassian automatically removes all data ingested by that connection from Teamwork Graph. Your onConnectionChange handler with action DELETED is the signal to stop ingestion and clean up any app-side resources (for example, stopping sync jobs or removing webhooks). You do not need to call any deletion APIs yourself on disconnect.
Rate this page: