Skip to content

Commit 83841a3

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
feat(tiktok): add app-level webhook ingress and triggers
1 parent 68bb622 commit 83841a3

24 files changed

Lines changed: 1436 additions & 5 deletions

apps/docs/content/docs/en/integrations/tiktok.mdx

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
1010
color="#000000"
1111
/>
1212

13+
{/* MANUAL-CONTENT-START:intro */}
14+
TikTok webhooks use a single app-level Callback URL configured in the [TikTok Developer Portal](https://developers.tiktok.com/):
15+
16+
`https://www.sim.ai/api/webhooks/tiktok`
17+
18+
(Use your environment’s `{APP_URL}/api/webhooks/tiktok` for staging or local ngrok.) You do not paste a unique per-workflow URL into TikTok. In Sim, connect a TikTok account on the trigger and deploy the workflow; events for that `user_openid` fan out to matching workflows.
19+
{/* MANUAL-CONTENT-END */}
20+
21+
1322
## Usage Instructions
1423

1524
Integrate TikTok into your workflow. Get user profile information including follower counts and video statistics. List and query videos with cover images, embed links, and metadata. Publish videos and photos directly to TikTok (or send them to the inbox as drafts) from a public URL or a file uploaded in the workflow, then track post status.
@@ -237,3 +246,139 @@ Check the status of a post initiated with Direct Post Video, Upload Video Draft,
237246
| `publiclyAvailablePostId` | array | Array of public post IDs \(as strings\) once the content is published and publicly viewable. Can be used to construct the TikTok post URL. |
238247

239248

249+
250+
## Triggers
251+
252+
A **Trigger** is a block that starts a workflow when an event happens in this service.
253+
254+
### TikTok Authorization Removed
255+
256+
Trigger when a user deauthorizes your TikTok app
257+
258+
#### Configuration
259+
260+
| Parameter | Type | Required | Description |
261+
| --------- | ---- | -------- | ----------- |
262+
| `triggerCredentials` | string | Yes | TikTok Account |
263+
264+
#### Output
265+
266+
| Parameter | Type | Description |
267+
| --------- | ---- | ----------- |
268+
| `event` | string | TikTok webhook event name |
269+
| `createTime` | number | UTC epoch seconds when the event occurred |
270+
| `userOpenId` | string | TikTok user open_id for the connected account |
271+
| `clientKey` | string | TikTok app client_key that received the event |
272+
| `reason` | number | Revocation reason \(0 unknown, 1 user disconnect, 2 account deleted, 3 age change, 4 banned, 5 developer revoke\) |
273+
274+
275+
---
276+
277+
### TikTok Post Inbox Delivered
278+
279+
Trigger when a draft notification is delivered to the creator inbox
280+
281+
#### Configuration
282+
283+
| Parameter | Type | Required | Description |
284+
| --------- | ---- | -------- | ----------- |
285+
| `triggerCredentials` | string | Yes | TikTok Account |
286+
287+
288+
---
289+
290+
### TikTok Post No Longer Public
291+
292+
Trigger when a post is no longer publicly viewable on TikTok
293+
294+
#### Configuration
295+
296+
| Parameter | Type | Required | Description |
297+
| --------- | ---- | -------- | ----------- |
298+
| `triggerCredentials` | string | Yes | TikTok Account |
299+
300+
301+
---
302+
303+
### TikTok Post Publicly Available
304+
305+
Trigger when a published post becomes publicly viewable on TikTok
306+
307+
#### Configuration
308+
309+
| Parameter | Type | Required | Description |
310+
| --------- | ---- | -------- | ----------- |
311+
| `triggerCredentials` | string | Yes | TikTok Account |
312+
313+
314+
---
315+
316+
### TikTok Post Publish Complete
317+
318+
Trigger when a TikTok Content Posting publish completes
319+
320+
#### Configuration
321+
322+
| Parameter | Type | Required | Description |
323+
| --------- | ---- | -------- | ----------- |
324+
| `triggerCredentials` | string | Yes | TikTok Account |
325+
326+
327+
---
328+
329+
### TikTok Post Publish Failed
330+
331+
Trigger when a TikTok Content Posting publish fails
332+
333+
#### Configuration
334+
335+
| Parameter | Type | Required | Description |
336+
| --------- | ---- | -------- | ----------- |
337+
| `triggerCredentials` | string | Yes | TikTok Account |
338+
339+
340+
---
341+
342+
### TikTok Video Publish Completed
343+
344+
Trigger when a Share Kit / Video Kit upload is published by the user
345+
346+
#### Configuration
347+
348+
| Parameter | Type | Required | Description |
349+
| --------- | ---- | -------- | ----------- |
350+
| `triggerCredentials` | string | Yes | TikTok Account |
351+
352+
#### Output
353+
354+
| Parameter | Type | Description |
355+
| --------- | ---- | ----------- |
356+
| `event` | string | TikTok webhook event name |
357+
| `createTime` | number | UTC epoch seconds when the event occurred |
358+
| `userOpenId` | string | TikTok user open_id for the connected account |
359+
| `clientKey` | string | TikTok app client_key that received the event |
360+
| `shareId` | string | Share Kit / Video Kit share_id |
361+
362+
363+
---
364+
365+
### TikTok Video Upload Failed
366+
367+
Trigger when a Share Kit / Video Kit upload fails in TikTok
368+
369+
#### Configuration
370+
371+
| Parameter | Type | Required | Description |
372+
| --------- | ---- | -------- | ----------- |
373+
| `triggerCredentials` | string | Yes | TikTok Account |
374+
375+
#### Output
376+
377+
| Parameter | Type | Description |
378+
| --------- | ---- | ----------- |
379+
| `event` | string | TikTok webhook event name |
380+
| `createTime` | number | UTC epoch seconds when the event occurred |
381+
| `userOpenId` | string | TikTok user open_id for the connected account |
382+
| `clientKey` | string | TikTok app client_key that received the event |
383+
| `shareId` | string | Share Kit / Video Kit share_id |
384+
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import {
5+
tiktokWebhookEnvelopeSchema,
6+
tiktokWebhookHeadersSchema,
7+
} from '@/lib/api/contracts/webhooks'
8+
import {
9+
API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
10+
isWorkspaceApiExecutionEntitled,
11+
} from '@/lib/billing/core/api-access'
12+
import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate'
13+
import { generateRequestId } from '@/lib/core/utils/request'
14+
import {
15+
assertContentLengthWithinLimit,
16+
isPayloadSizeLimitError,
17+
readStreamToBufferWithLimit,
18+
} from '@/lib/core/utils/stream-limits'
19+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
20+
import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
21+
import {
22+
checkWebhookPreprocessing,
23+
handlePreDeploymentVerification,
24+
queueWebhookExecution,
25+
shouldSkipWebhookEvent,
26+
} from '@/lib/webhooks/processor'
27+
import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok'
28+
import { findTikTokWebhooksForOpenId } from '@/lib/webhooks/tiktok-fanout'
29+
import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
30+
31+
const logger = createLogger('TikTokWebhookIngress')
32+
33+
const TIKTOK_BODY_LABEL = 'TikTok webhook body'
34+
35+
export const dynamic = 'force-dynamic'
36+
export const runtime = 'nodejs'
37+
export const maxDuration = 60
38+
39+
async function readTikTokBody(req: Request): Promise<string> {
40+
assertContentLengthWithinLimit(req.headers, WEBHOOK_MAX_BODY_BYTES, TIKTOK_BODY_LABEL)
41+
const buffer = await readStreamToBufferWithLimit(req.body, {
42+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
43+
label: TIKTOK_BODY_LABEL,
44+
})
45+
return new TextDecoder().decode(buffer)
46+
}
47+
48+
/**
49+
* App-level TikTok webhook Callback URL.
50+
* Portal: `{APP_URL}/api/webhooks/tiktok` (e.g. https://www.sim.ai/api/webhooks/tiktok).
51+
* Verifies TikTok-Signature once, then fans out by user_openid → credential → workflows.
52+
*/
53+
export const POST = withRouteHandler(async (request: NextRequest) => {
54+
const ticket = tryAdmit()
55+
if (!ticket) {
56+
return admissionRejectedResponse()
57+
}
58+
59+
const requestId = generateRequestId()
60+
const receivedAt = Date.now()
61+
62+
try {
63+
let rawBody: string
64+
try {
65+
rawBody = await readTikTokBody(request)
66+
} catch (bodyError) {
67+
if (isPayloadSizeLimitError(bodyError)) {
68+
logger.warn(`[${requestId}] Rejected oversized TikTok webhook body`, {
69+
maxBytes: WEBHOOK_MAX_BODY_BYTES,
70+
observedBytes: bodyError.observedBytes,
71+
})
72+
return NextResponse.json({ error: 'Request body too large' }, { status: 413 })
73+
}
74+
throw bodyError
75+
}
76+
77+
const headersResult = tiktokWebhookHeadersSchema.safeParse({
78+
'tiktok-signature': request.headers.get('TikTok-Signature'),
79+
})
80+
if (!headersResult.success) {
81+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
82+
}
83+
84+
const authError = verifyTikTokSignature(
85+
rawBody,
86+
headersResult.data['tiktok-signature'],
87+
requestId
88+
)
89+
if (authError) {
90+
return authError
91+
}
92+
93+
let parsedJson: unknown
94+
try {
95+
parsedJson = rawBody ? JSON.parse(rawBody) : {}
96+
} catch {
97+
logger.warn(`[${requestId}] TikTok webhook body is not valid JSON`)
98+
// Ack to avoid retry storms on malformed payloads after a valid signature.
99+
return NextResponse.json({ ok: true })
100+
}
101+
102+
const envelopeResult = tiktokWebhookEnvelopeSchema.safeParse(parsedJson)
103+
if (!envelopeResult.success) {
104+
logger.warn(`[${requestId}] Invalid TikTok webhook envelope`, {
105+
issues: envelopeResult.error.issues,
106+
})
107+
return NextResponse.json({ ok: true })
108+
}
109+
110+
const envelope = envelopeResult.data
111+
const matches = await findTikTokWebhooksForOpenId(envelope.user_openid, requestId)
112+
113+
if (matches.length === 0) {
114+
logger.info(`[${requestId}] No matching TikTok webhooks; acknowledging`, {
115+
event: envelope.event,
116+
userOpenIdPrefix: envelope.user_openid.slice(0, 12),
117+
})
118+
return NextResponse.json({ ok: true })
119+
}
120+
121+
let processed = 0
122+
for (const { webhook: foundWebhook, workflow: foundWorkflow } of matches) {
123+
// Schema allows null provider; fan-out already filtered to provider = 'tiktok'.
124+
const webhookRecord = {
125+
...foundWebhook,
126+
provider: foundWebhook.provider ?? 'tiktok',
127+
providerConfig:
128+
(foundWebhook.providerConfig as Record<string, unknown> | null) ?? undefined,
129+
}
130+
131+
if (
132+
foundWorkflow.workspaceId &&
133+
!(await isWorkspaceApiExecutionEntitled(foundWorkflow.workspaceId))
134+
) {
135+
logger.warn(`[${requestId}] Workspace not entitled for TikTok webhook`, {
136+
webhookId: webhookRecord.id,
137+
workspaceId: foundWorkflow.workspaceId,
138+
})
139+
continue
140+
}
141+
142+
const preprocessResult = await checkWebhookPreprocessing(
143+
foundWorkflow,
144+
webhookRecord,
145+
requestId
146+
)
147+
if (preprocessResult.error) {
148+
logger.warn(`[${requestId}] Preprocessing failed for TikTok webhook`, {
149+
webhookId: webhookRecord.id,
150+
})
151+
continue
152+
}
153+
154+
if (webhookRecord.blockId) {
155+
const blockExists = await blockExistsInDeployment(foundWorkflow.id, webhookRecord.blockId)
156+
if (!blockExists) {
157+
const preDeploymentResponse = handlePreDeploymentVerification(webhookRecord, requestId)
158+
if (preDeploymentResponse) {
159+
continue
160+
}
161+
logger.info(
162+
`[${requestId}] Trigger block ${webhookRecord.blockId} not found in deployment for workflow ${foundWorkflow.id}`
163+
)
164+
continue
165+
}
166+
}
167+
168+
if (shouldSkipWebhookEvent(webhookRecord, envelope, requestId)) {
169+
continue
170+
}
171+
172+
await queueWebhookExecution(webhookRecord, foundWorkflow, envelope, request, {
173+
requestId,
174+
path: webhookRecord.path,
175+
actorUserId: preprocessResult.actorUserId,
176+
executionId: preprocessResult.executionId,
177+
correlation: preprocessResult.correlation,
178+
receivedAt,
179+
})
180+
processed += 1
181+
}
182+
183+
if (processed === 0 && matches.length > 0) {
184+
logger.info(`[${requestId}] TikTok webhooks matched but none processed`, {
185+
matchCount: matches.length,
186+
hint: API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE,
187+
})
188+
}
189+
190+
return NextResponse.json({ ok: true, webhooksProcessed: processed })
191+
} catch (error) {
192+
logger.error(`[${requestId}] TikTok webhook ingress error`, {
193+
error: getErrorMessage(error, 'Unknown error'),
194+
})
195+
// Still 200 after accept path failures that aren't auth — TikTok retries on non-200.
196+
return NextResponse.json({ ok: true })
197+
} finally {
198+
ticket.release()
199+
}
200+
})

apps/sim/blocks/blocks/tiktok.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { BlockConfig, BlockMeta } from '@/blocks/types'
44
import { AuthMode, IntegrationType } from '@/blocks/types'
55
import { normalizeFileInput } from '@/blocks/utils'
66
import type { TikTokResponse } from '@/tools/tiktok/types'
7+
import { getTrigger } from '@/triggers'
78

89
const VIDEO_POST_OPERATIONS = ['tiktok_direct_post_video', 'tiktok_upload_video_draft']
910
const PHOTO_POST_OPERATIONS = ['tiktok_direct_post_photo', 'tiktok_upload_photo_draft']
@@ -362,8 +363,31 @@ export const TikTokBlock: BlockConfig<TikTokResponse> = {
362363
condition: { field: 'operation', value: 'tiktok_get_post_status' },
363364
required: { field: 'operation', value: 'tiktok_get_post_status' },
364365
},
366+
367+
...getTrigger('tiktok_post_publish_complete').subBlocks,
368+
...getTrigger('tiktok_post_publish_failed').subBlocks,
369+
...getTrigger('tiktok_post_inbox_delivered').subBlocks,
370+
...getTrigger('tiktok_post_publicly_available').subBlocks,
371+
...getTrigger('tiktok_post_no_longer_public').subBlocks,
372+
...getTrigger('tiktok_video_publish_completed').subBlocks,
373+
...getTrigger('tiktok_video_upload_failed').subBlocks,
374+
...getTrigger('tiktok_authorization_removed').subBlocks,
365375
],
366376

377+
triggers: {
378+
enabled: true,
379+
available: [
380+
'tiktok_post_publish_complete',
381+
'tiktok_post_publish_failed',
382+
'tiktok_post_inbox_delivered',
383+
'tiktok_post_publicly_available',
384+
'tiktok_post_no_longer_public',
385+
'tiktok_video_publish_completed',
386+
'tiktok_video_upload_failed',
387+
'tiktok_authorization_removed',
388+
],
389+
},
390+
367391
tools: {
368392
access: [
369393
'tiktok_get_user',

0 commit comments

Comments
 (0)