11import { createLogger } from '@sim/logger'
22import { getErrorMessage } from '@sim/utils/errors'
33import { 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'
4+ import { tiktokWebhookEnvelopeSchema } from '@/lib/api/contracts/webhooks'
125import { admissionRejectedResponse , tryAdmit } from '@/lib/core/admission/gate'
6+ import { getJobQueue } from '@/lib/core/async-jobs'
7+ import { env } from '@/lib/core/config/env'
138import { generateRequestId } from '@/lib/core/utils/request'
149import {
1510 assertContentLengthWithinLimit ,
@@ -18,30 +13,13 @@ import {
1813} from '@/lib/core/utils/stream-limits'
1914import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2015import { WEBHOOK_MAX_BODY_BYTES } from '@/lib/webhooks/constants'
21- import {
22- checkWebhookPreprocessing ,
23- handlePreDeploymentVerification ,
24- queueWebhookExecution ,
25- shouldSkipWebhookEvent ,
26- } from '@/lib/webhooks/processor'
2716import { verifyTikTokSignature } from '@/lib/webhooks/providers/tiktok'
28- import { findTikTokWebhooksForOpenId } from '@/lib/webhooks/tiktok-fanout'
29- import { blockExistsInDeployment } from '@/lib/workflows/persistence/utils'
30- import { isTikTokEventMatch } from '@/triggers/tiktok/utils'
31-
32- /**
33- * queueWebhookExecution returns 200 for both real queues and event mismatches.
34- * Only count responses that indicate work was actually enqueued.
35- */
36- async function didQueueWebhookExecution ( response : NextResponse ) : Promise < boolean > {
37- if ( ! response . ok ) return false
38- try {
39- const payload = ( await response . clone ( ) . json ( ) ) as Record < string , unknown >
40- return payload . message === 'Webhook processed'
41- } catch {
42- return false
43- }
44- }
17+ import {
18+ executeTikTokWebhookIngress ,
19+ TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT ,
20+ TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS ,
21+ type TikTokWebhookIngressPayload ,
22+ } from '@/background/tiktok-webhook-ingress'
4523
4624const logger = createLogger ( 'TikTokWebhookIngress' )
4725
@@ -63,7 +41,7 @@ async function readTikTokBody(req: Request): Promise<string> {
6341/**
6442 * App-level TikTok webhook Callback URL.
6543 * Portal: `{APP_URL}/api/webhooks/tiktok` (e.g. https://www.sim.ai/api/webhooks/tiktok).
66- * Verifies TikTok-Signature once, then fans out by user_openid → credential → workflows .
44+ * Verifies TikTok-Signature and durably accepts the delivery before background target fanout .
6745 */
6846export const POST = withRouteHandler ( async ( request : NextRequest ) => {
6947 const ticket = tryAdmit ( )
@@ -89,16 +67,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
8967 throw bodyError
9068 }
9169
92- const headersResult = tiktokWebhookHeadersSchema . safeParse ( {
93- 'tiktok-signature' : request . headers . get ( 'TikTok-Signature' ) ,
94- } )
95- if ( ! headersResult . success ) {
96- return NextResponse . json ( { error : 'Unauthorized' } , { status : 401 } )
97- }
98-
9970 const authError = verifyTikTokSignature (
10071 rawBody ,
101- headersResult . data [ 'tiktok-signature' ] ,
72+ request . headers . get ( 'TikTok-Signature' ) ,
10273 requestId
10374 )
10475 if ( authError ) {
@@ -123,109 +94,41 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
12394 }
12495
12596 const envelope = envelopeResult . data
126- const matches = await findTikTokWebhooksForOpenId ( envelope . user_openid , requestId )
127-
128- if ( matches . length === 0 ) {
129- logger . info ( `[${ requestId } ] No matching TikTok webhooks; acknowledging` , {
130- event : envelope . event ,
131- userOpenIdPrefix : envelope . user_openid . slice ( 0 , 12 ) ,
132- } )
133- return NextResponse . json ( { ok : true } )
97+ if ( ! env . TIKTOK_CLIENT_ID || envelope . client_key !== env . TIKTOK_CLIENT_ID ) {
98+ logger . warn ( `[${ requestId } ] TikTok webhook client_key does not match configured app` )
99+ return NextResponse . json ( { error : 'Unauthorized' } , { status : 401 } )
134100 }
135101
136- let processed = 0
137- for ( const { webhook : foundWebhook , workflow : foundWorkflow } of matches ) {
138- // Schema allows null provider; fan-out already filtered to provider = 'tiktok'.
139- const webhookRecord = {
140- ...foundWebhook ,
141- provider : foundWebhook . provider ?? 'tiktok' ,
142- providerConfig :
143- ( foundWebhook . providerConfig as Record < string , unknown > | null ) ?? undefined ,
144- }
145-
146- if (
147- foundWorkflow . workspaceId &&
148- ! ( await isWorkspaceApiExecutionEntitled ( foundWorkflow . workspaceId ) )
149- ) {
150- logger . warn ( `[${ requestId } ] Workspace not entitled for TikTok webhook` , {
151- webhookId : webhookRecord . id ,
152- workspaceId : foundWorkflow . workspaceId ,
153- } )
154- continue
155- }
156-
157- const preprocessResult = await checkWebhookPreprocessing (
158- foundWorkflow ,
159- webhookRecord ,
160- requestId
161- )
162- if ( preprocessResult . error ) {
163- logger . warn ( `[${ requestId } ] Preprocessing failed for TikTok webhook` , {
164- webhookId : webhookRecord . id ,
165- } )
166- continue
167- }
168-
169- if ( webhookRecord . blockId ) {
170- const blockExists = await blockExistsInDeployment ( foundWorkflow . id , webhookRecord . blockId )
171- if ( ! blockExists ) {
172- const preDeploymentResponse = handlePreDeploymentVerification ( webhookRecord , requestId )
173- if ( preDeploymentResponse ) {
174- continue
175- }
176- logger . info (
177- `[${ requestId } ] Trigger block ${ webhookRecord . blockId } not found in deployment for workflow ${ foundWorkflow . id } `
178- )
179- continue
180- }
181- }
182-
183- if ( shouldSkipWebhookEvent ( webhookRecord , envelope , requestId ) ) {
184- continue
185- }
186-
187- const triggerId = webhookRecord . providerConfig ?. triggerId
188- if (
189- typeof triggerId === 'string' &&
190- triggerId . length > 0 &&
191- ! isTikTokEventMatch ( triggerId , envelope . event )
192- ) {
193- continue
194- }
195-
196- const queueResponse = await queueWebhookExecution (
197- webhookRecord ,
198- foundWorkflow ,
199- envelope ,
200- request ,
201- {
202- requestId,
203- path : webhookRecord . path ,
204- actorUserId : preprocessResult . actorUserId ,
205- executionId : preprocessResult . executionId ,
206- correlation : preprocessResult . correlation ,
207- receivedAt,
208- }
209- )
210- if ( await didQueueWebhookExecution ( queueResponse ) ) {
211- processed += 1
212- }
102+ const payload : TikTokWebhookIngressPayload = {
103+ envelope,
104+ headers : {
105+ 'content-type' : request . headers . get ( 'content-type' ) ?? 'application/json' ,
106+ } ,
107+ requestId,
108+ receivedAt,
213109 }
110+ const jobQueue = await getJobQueue ( )
111+ const jobId = await jobQueue . enqueue ( 'tiktok-webhook-ingress' , payload , {
112+ maxAttempts : TIKTOK_WEBHOOK_INGRESS_MAX_ATTEMPTS ,
113+ concurrencyKey : 'tiktok-webhook-ingress' ,
114+ concurrencyLimit : TIKTOK_WEBHOOK_INGRESS_CONCURRENCY_LIMIT ,
115+ runner : async ( ) => {
116+ await executeTikTokWebhookIngress ( payload )
117+ } ,
118+ } )
214119
215- if ( processed === 0 && matches . length > 0 ) {
216- logger . info ( `[${ requestId } ] TikTok webhooks matched but none processed` , {
217- matchCount : matches . length ,
218- hint : API_EXECUTION_REQUIRES_PAID_PLAN_MESSAGE ,
219- } )
220- }
120+ logger . info ( `[${ requestId } ] Accepted TikTok webhook delivery` , {
121+ event : envelope . event ,
122+ jobId,
123+ userOpenIdPrefix : envelope . user_openid . slice ( 0 , 12 ) ,
124+ } )
221125
222- return NextResponse . json ( { ok : true , webhooksProcessed : processed } )
126+ return NextResponse . json ( { ok : true } )
223127 } catch ( error ) {
224128 logger . error ( `[${ requestId } ] TikTok webhook ingress error` , {
225129 error : getErrorMessage ( error , 'Unknown error' ) ,
226130 } )
227- // Still 200 after accept path failures that aren't auth — TikTok retries on non-200.
228- return NextResponse . json ( { ok : true } )
131+ return NextResponse . json ( { error : 'Temporarily unable to accept webhook' } , { status : 503 } )
229132 } finally {
230133 ticket . release ( )
231134 }
0 commit comments