yikart/AiToEarn · warning

ChannelWebhookChallengeCodeMissing

Error message

ChannelWebhookChallengeCodeMissing

What it means

LinkedIn's challenge handshake sends a challengeCode query parameter; the provider must answer with an HMAC-SHA256 (hex) of that code. If challengeCode is absent the provider cannot build the response and returns 400 with ChannelWebhookChallengeCodeMissing.

Source

Thrown at project/aitoearn-backend/apps/aitoearn-server/src/core/channels/platforms/linkedin/linkedin-webhook.provider.ts:41

      return
    }
    if (!this.verify(request)) {
      this.logger.warn({ platform: AccountType.LinkedIn }, 'LinkedIn webhook signature invalid')
      response.status(401).send(getCodeMessage(ResponseCode.ChannelWebhookInvalidSignature, undefined, getLocale()))
      return
    }
    const payload: LinkedInWebhookPayload = LinkedInWebhookPayloadSchema.parse(request.body)
    this.logger.log({
      platform: AccountType.LinkedIn,
      eventCount: payload.events?.length ?? 0,
    }, 'LinkedIn webhook events received')
    response.status(200).json({ status: 'ok' })
  }

  private handleChallenge(request: Request, response: Response): void {
    const query = request.query as LinkedInWebhookChallengeQuery
    if (!query.challengeCode) {
      response.status(400).send(getCodeMessage(ResponseCode.ChannelWebhookChallengeCodeMissing, undefined, getLocale()))
      return
    }

    const secret = this.config.webhookSecret || this.config.clientSecret
    const challengeResponse = createHmac('sha256', secret).update(query.challengeCode).digest('hex')
    response.status(200).json({ challengeCode: query.challengeCode, challengeResponse })
  }

  private verify(request: RawBodyRequest): boolean {
    const rawBody = request.rawBody
    const signature = this.getHeader(request, 'x-li-signature')
    const secret = this.config.webhookSecret || this.config.clientSecret
    if (!signature || !rawBody || !secret) {
      return false
    }

    const digest = createHmac('sha256', secret).update(rawBody).digest('hex')
    const expected = signature.startsWith('sha256=') ? `sha256=${digest}` : digest

View on GitHub (pinned to d3aa8bea5b)

Solutions

  1. Register the exact webhook URL LinkedIn documents so the challenge flow includes challengeCode
  2. Exclude the webhook GET endpoint from synthetic health checks or give monitors a dedicated health route
  3. Test the handshake by replicating LinkedIn's documented GET with challengeCode set

Example fix

// before
curl https://api.example.com/webhooks/linkedin
// after
curl "https://api.example.com/webhooks/linkedin?challengeCode=abc123"
Defensive patterns

Strategy: validation

Validate before calling

const url = new URL(callbackUrl)
if (!url.searchParams.get('challengeCode')) {
  console.warn('LinkedIn challenge handshake requires challengeCode query param')
}

Type guard

function hasChallengeCode(q: unknown): q is { challengeCode: string } {
  return typeof (q as any)?.challengeCode === 'string' && (q as any).challengeCode.length > 0
}

Prevention

When it happens

Trigger: GET request to the LinkedIn webhook endpoint without a challengeCode query parameter (e.g., health checks, browsers, or misconfigured subscription URLs).

Common situations: Uptime monitors pinging the webhook GET URL; LinkedIn configured with a wrong callback URL that omits the challenge flow; manual URL testing without query params.

Related errors


AI-assisted analysis of yikart/AiToEarn@d3aa8bea5b (2026-08-31). Data as JSON: /api/errors/33e6611ec7ac6aae. Report an issue: GitHub.