Skip to content

bug: TriggerApiError: Cannot trigger MY_TASK with a one-time use token as it has already been used. #3780

@cachho

Description

@cachho

Provide environment information

Nextjs 14

System:
OS: Linux 6.6 Ubuntu 20.04.6 LTS (Focal Fossa)
CPU: (12) x64 AMD Ryzen 5 5600X 6-Core Processor
Memory: 2.80 GB / 15.58 GB
Container: Yes
Shell: 3.7.1 - /usr/bin/fish
Binaries:
Node: 22.19.0 - /home/c/.local/share/nvm/v22.19.0/bin/node
npm: 10.9.3 - /home/c/.local/share/nvm/v22.19.0/bin/npm
pnpm: 8.6.3 - /home/c/.local/share/pnpm/pnpm
bun: 1.3.14 - /home/c/.bun/bin/bun
Deno: 2.1.4 - /home/c/.deno/bin/deno

Describe the bug

The error says that my token has already been used and I get http status code 422. But I only see one request to api.trigger.dev in the browser network tools, and that one's already getting 422.

Reproduction repo

I hope I don't need to

To reproduce

My code worked before, all I did was update to 4.4.6 - although I cannot be sure if it previous versions were already broken.

Token is generated via Server action.

Image

I click submit, which should submit the trigger task. Note that now there is one call to the trigger.dev API in the network tab, and that is already getting the error TriggerApiError: Cannot trigger decrypt-link with a one-time use token as it has already been used..

Image

Nothing else happening on the server.

Image

The OPTIONS request is there sometimes and sometimes it's not, but it's after the fact anyways

Image

Additional information

For the test I removed the recreate logic, just in case that's leading to some timing issues.

Some files that were used in the screenshots.

useDecryptTask.ts

'use client';

import { useTaskTrigger } from '@trigger.dev/react-hooks';
import type { TriggerOptions } from '@trigger.dev/sdk/v3';
import { useCallback, useEffect, useState } from 'react';

import type { decryptLink } from '../../../trigger/decrypt-link';
import { createTriggerToken } from '../../../utils/trigger';

/**
 * Custom hook to manage the decryption task.
 *
 * Solves the issue of needing to create a trigger token for the decryption task.
 * Handles one-time use tokens by clearing after submission.
 *
 * Avoids Suspense issues by properly handling the promise.
 */
export function useDecryptTask() {
  const [triggerToken, setTriggerToken] = useState<string>('placeholder-token');
  const [isTokenReady, setIsTokenReady] = useState(false);
  const [isTokenLoading, setIsTokenLoading] = useState(false);

  // Create a function to fetch token that can be called multiple times
  const fetchToken = useCallback(async () => {
    if (isTokenLoading) return null;

    setIsTokenLoading(true);
    try {
      const token = await createTriggerToken('decrypt-link');
      console.log('Created trigger token:', token.slice(-5));
      setTriggerToken(token);
      setIsTokenReady(true);
      return token;
    } catch (err) {
      console.error('Failed to create trigger token:', err);
      return null;
    } finally {
      setIsTokenLoading(false);
    }
  }, [isTokenLoading]);

  // Function to clear token after use
  const clearToken = useCallback(() => {
    console.debug('Clearing trigger token after use');
    setTriggerToken('placeholder-token');
    setIsTokenReady(false);
  }, []);

  // Load the token initially - but in an effect, not during render
  useEffect(() => {
    let isMounted = true;

    const loadInitialToken = async () => {
      try {
        const token = await createTriggerToken('decrypt-link');
        console.log('🚀 ~ loadInitialToken ~ token:', token.slice(-5));
        if (isMounted) {
          setTriggerToken(token);
          setIsTokenReady(true);
        }
      } catch (err) {
        console.error('Failed to create initial trigger token:', err);
      } finally {
        if (isMounted) {
          setIsTokenLoading(false);
        }
      }
    };

    setIsTokenLoading(true);
    loadInitialToken();

    return () => {
      isMounted = false;
    };
  }, []);

  // Always call the hook with a valid string (even if placeholder)
  const taskTrigger = useTaskTrigger<typeof decryptLink>('decrypt-link', {
    accessToken: triggerToken,
    enabled: isTokenReady,
  });

  // Wrap submit to also clear the token after use
  const submit = useCallback(
    async (
      params: Parameters<typeof taskTrigger.submit>[0],
      options: TriggerOptions
    ) => {
      if (!isTokenReady) {
        return Promise.resolve();
      }

      try {
        taskTrigger.submit(params, options);
        return await Promise.resolve();
      } catch (error) {
        // Clear token even on error - it's already been consumed
        clearToken();
        throw error;
      }
    },
    [taskTrigger, isTokenReady, clearToken]
  );

  return {
    ...taskTrigger,
    submit,
    isTokenReady,
    isTokenLoading,
    recreateToken: fetchToken,
    token: triggerToken, // expose token for debugging
  };
}

createTriggerToken.ts

'use server';

import { auth } from '@trigger.dev/sdk';

/**
 * Server action to create a trigger token for the "decrypt-link" task.
 *
 * Quote:
 * To authenticate a trigger hook, you must provide a special one-time use “trigger” token.
 * These tokens are very similar to Public Access Tokens, but they can only be used once to trigger a task.
 * You can generate a trigger token using the auth.createTriggerPublicToken function in your backend code:
 *
 * @param task The name of the task for which the trigger token is created.
 * @returns
 */
export async function createTriggerToken(task: string) {
  const performanceStart = performance.now();
  const token = await auth.createTriggerPublicToken(task, {
    expirationTime: '24hr',
  });
  const performanceEnd = performance.now();
  console.log(
    `Created trigger token for task "${task}" in ${
      performanceEnd - performanceStart
    } ms, token: ${token.slice(-5)}` // last 5 letters of token
  );
  return token;
}

Some background, what I'm trying to achieve. This is a short running task. I initially used to submit-and-await logic trigger has, that would return it in one go. Now I'm using the handler logic. I create the token on page load, so when the user hits submit, the token is there at least, to make it quicker, because, as I said, this is a short running task so the 300ms to create the token makes a difference.

Also tried to chat with the discord bot, searching the docs, github issues, discussions, google etc.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions