diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..69c76e1 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,131 @@ +# Migrating from 4.x to 5.x + +Version 5 is generated from the OpenAPI spec with a new generator. Most calls keep their names. Check each item below before you upgrade. + +```bash +npm install @moderation-api/sdk@5 +``` + +## Client + +- The client class is `ModerationApi` (was `ModerationAPI`). It is also the default export, so `import ModerationApi from "@moderation-api/sdk"` keeps working. +- The client reads the API key from `MODAPI_SECRET_KEY`. To set it in code, pass `secretKey`. + +## Imports + +The `@moderation-api/sdk/resources` import path is removed. Import types from: + +| Types | Import path | +| --- | --- | +| Requests and responses | `@moderation-api/sdk/models/operations` | +| Shared types, for example `PublicAuthor` | `@moderation-api/sdk/models` | +| Error classes | `@moderation-api/sdk/models/errors` | +| Webhook events, `validateWebhook` | `@moderation-api/sdk` | + +## Renamed and removed methods + +| 4.x | 5.x | +| --- | --- | +| `account.list()` | `account.retrieve()` | +| `actions.execute.execute(params)` | `actions.execute(params)` | +| `actions.execute.executeByID(id, params)` | `actions.execute({ actionId, ... })` or `actions.execute({ actionKey, ... })` | +| `webhooks.constructEvent(rawBody, signature, secret)` | `await validateWebhook({ request, secret })` | +| `auth.*` | Removed | +| `content.stream` | Removed. See [Real-time voice](https://docs.moderationapi.com/content-moderation/real-time-voice). | + +## Webhooks + +`webhooks.constructEvent` was synchronous and took the raw body and the signature. `validateWebhook` is async, is exported from the package root and takes the whole request: a Fetch `Request`, or a Node.js request (Express, Next.js Pages Router). It reads the secret from `MODAPI_WEBHOOK_SECRET` when you do not pass `secret`. + +```typescript +// 4.x +const event = client.webhooks.constructEvent(rawBody, signature, secret); + +// 5.x +import { validateWebhook } from "@moderation-api/sdk"; +const event = await validateWebhook({ request: req }); +``` + +Webhook payloads keep the API's snake_case field names (`flagged_fields`, `external_id`). Only the client's requests and responses use camelCase. + +## camelCase fields + +Request and response fields are camelCase. The 4.x types used the API's snake_case names. For example, `authors.create` and `authors.update`: + +| 4.x | 5.x | +| --- | --- | +| `external_id` | `externalId` | +| `external_link` | `externalLink` | +| `profile_picture` | `profilePicture` | +| `first_seen` | `firstSeen` | +| `last_seen` | `lastSeen` | +| `manual_trust_level` | `manualTrustLevel` | +| `metadata.email_verified` | `metadata.emailVerified` | +| `metadata.phone_verified` | `metadata.phoneVerified` | +| `metadata.identity_verified` | `metadata.identityVerified` | +| `metadata.is_paying_customer` | `metadata.isPayingCustomer` | + +The same applies to responses, for example `author.trustLevel`, `result.evaluation.flagProbability` and `result.recommendation.reasonCodes`. + +## Lists + +List methods take `page` and `pageSize`, and return `{ data, pagination }` instead of a plain array or a resource-specific shape. You can also iterate over the result to get every page. + +## Errors + +There is one error class per HTTP status (`BadRequestError`, `NotFoundError`, `RateLimitError`, ...). `code` is the specific error code (for example `invalid_api_key`), not the HTTP category. The old `data` field is removed. + +## Organization keys + +Organization keys work only with `projects` and `channels`. Project keys work with everything else. Use one client per key. + +## Renamed types + +| 4.x (`@moderation-api/sdk/resources`) | 5.x | +| --- | --- | +| `AccountListResponse` | `GetAccountResponse` | +| `ActionCreateParams` | `CreateActionRequestBody` | +| `ActionCreateResponse` | `CreateActionResponse` | +| `ActionRetrieveResponse` | `GetActionResponse` | +| `ActionUpdateParams` | `UpdateActionRequestBody` | +| `ActionUpdateResponse` | `UpdateActionResponse` | +| `ActionListParams` | `ListActionsRequest` | +| `ActionListResponse` | `ListActionsResponse` | +| `ActionDeleteResponse` | `DeleteActionResponse` | +| `ExecuteExecuteParams` | `ExecuteActionRequestBody` | +| `ExecuteExecuteResponse` | `ExecuteActionResponse` | +| `AuthorCreateParams` | `CreateAuthorRequestBody` | +| `AuthorUpdateParams` | `UpdateAuthorRequestBody` | +| `AuthorListParams` | `ListAuthorsRequest` | +| `AuthorCreateResponse`, `AuthorRetrieveResponse`, `AuthorUpdateResponse` | `PublicAuthor` (`@moderation-api/sdk/models`) | +| `AuthorListResponse` | `ListAuthorsResponse` | +| `AuthorDeleteResponse` | `DeleteAuthorResponse` | +| `ContentSubmitParams` | `SubmitContentRequestBody` | +| `ContentSubmitResponse` | `SubmitContentResponse` | +| `ItemListParams` | `ListQueueItemsRequest` | +| `ItemListResponse` | `ListQueueItemsResponse` | +| `ItemResolveParams` | `ResolveQueueItemRequestBody` | +| `ItemResolveResponse` | `ResolveQueueItemResponse` | +| `ItemUnresolveParams` | `UnresolveQueueItemRequestBody` | +| `ItemUnresolveResponse` | `UnresolveQueueItemResponse` | +| `QueueRetrieveResponse` | `GetQueueResponse` | +| `QueueGetStatsParams` | `GetQueueStatsRequest` | +| `QueueGetStatsResponse` | `GetQueueStatsResponse` | +| `WebhookEvent` | `WebhookEvent` (`@moderation-api/sdk`) | +| `WebhookSecretRetrieveResponse` | `GetWebhookSecretResponse` | +| `WebhookCreateParams` | `CreateWebhookRequestBody` | +| `WebhookCreateResponse` | `CreateWebhookResponse` | +| `WebhookRetrieveResponse` | `GetWebhookResponse` | +| `WebhookUpdateParams` | `UpdateWebhookRequestBody` | +| `WebhookUpdateResponse` | `UpdateWebhookResponse` | +| `WebhookListResponse` | `ListWebhooksResponse` | +| `WebhookDeleteResponse` | `DeleteWebhookResponse` | +| `WordlistRetrieveResponse` | `GetWordlistResponse` | +| `WordlistUpdateParams` | `UpdateWordlistRequestBody` | +| `WordlistUpdateResponse` | `UpdateWordlistResponse` | +| `WordlistListResponse` | `ListWordlistsResponse` | +| `WordlistGetEmbeddingStatusResponse` | `GetWordlistEmbeddingStatusResponse` | +| `WordAddParams` | `AddWordlistWordsRequestBody` | +| `WordAddResponse` | `AddWordlistWordsResponse` | +| `WordRemoveParams` | `RemoveWordlistWordsRequest` | +| `WordRemoveResponse` | `RemoveWordlistWordsResponse` | diff --git a/README.md b/README.md index e1f3994..324d63c 100644 --- a/README.md +++ b/README.md @@ -182,19 +182,28 @@ switch (result.recommendation.action) { case "reject": // Block the content break; - case "review": - // Publish or hold it, and send it to a review queue - break; case "allow": // Publish the content break; + case "review": + default: + // Publish or hold it, and send it to a review queue. + // `default` also catches actions added to the API later. + break; } for (const policy of result.policies) { console.log(`${policy.id}: flagged=${policy.flagged}`); } + +// For object content: the fields that any flagging policy flagged +console.log(result.evaluation.flaggedFields); ``` +### Values added later + +Some fields are open enums, for example `recommendation.action` and the error `code`. The API can add a value, and an older SDK version then returns it as a plain string. It does not throw. Always handle unknown values: for `recommendation.action`, treat an unknown value as `review`, as the `default` branch above does. + ## Verifying webhooks Moderation API signs every webhook delivery with your project's webhook signing secret. The signature is in the `modapi-signature` header. @@ -266,6 +275,36 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } } ``` + +### Handle an event + +`validateWebhook` returns a `WebhookEvent`, a union of the event types. Switch on `event.type` and TypeScript knows the shape of `event.data.object`. Each event also has its own type, for example `QueueItemRejectedEvent` or `AuthorBlockedEvent`. + +Webhook payloads use the API's field names, in snake_case (`flagged_fields`, `external_id`). The request and response objects of the client methods use camelCase (`flaggedFields`, `externalId`). + +The payload is not validated against a schema, so a field or an event type added to the API later never fails a delivery. An event type that this SDK version does not know reaches the `default` branch. Read it as `UnknownWebhookEvent`. + +```typescript +import { validateWebhook, type UnknownWebhookEvent } from "@moderation-api/sdk"; + +const event = await validateWebhook({ request }); + +switch (event.type) { + case "queue_item.rejected": { + const { item, author } = event.data.object; + console.log(item?.id, item?.flagged_fields, author?.external_id); + break; + } + case "author.blocked": { + console.log(event.data.object.author.external_id); + break; + } + default: { + const unknown = event as UnknownWebhookEvent; + console.log(`Unhandled event ${unknown.type}`); + } +} +``` @@ -776,13 +815,16 @@ import ModerationApi from "@moderation-api/sdk"; import { ModerationApi } from "@moderation-api/sdk"; ``` -Request and response types are in `@moderation-api/sdk/models/operations`, shared types in `@moderation-api/sdk/models`, error classes in `@moderation-api/sdk/models/errors` and webhook events in `@moderation-api/sdk/models/webhooks`: +Request and response types are in `@moderation-api/sdk/models/operations`, shared types in `@moderation-api/sdk/models`, error classes in `@moderation-api/sdk/models/errors`. Webhook event types (`WebhookEvent`, `QueueItemRejectedEvent`, ...) are exported from the package root, next to `validateWebhook`: ```typescript import type { SubmitContentRequest, SubmitContentResponse } from "@moderation-api/sdk/models/operations"; import { PermissionDeniedError } from "@moderation-api/sdk/models/errors"; +import type { WebhookEvent } from "@moderation-api/sdk"; ``` +Upgrading from 4.x? Read [MIGRATION.md](MIGRATION.md). + ## Timeouts Set a timeout in milliseconds for all requests on the client, or for one request: diff --git a/src/webhook-events.ts b/src/webhook-events.ts new file mode 100644 index 0000000..6b4ed5f --- /dev/null +++ b/src/webhook-events.ts @@ -0,0 +1,619 @@ +/* + * Webhook event types, written by scripts/webhook-types.py in + * moderation-api/sdk-generator from the webhooks in the API spec. Do not edit. + * + * Payloads use the API's snake_case field names. + */ + +export type AuthorBlockedEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "author.blocked"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookActionPerformedAuthor; + }; +}; + +export type AuthorUnblockedEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "author.unblocked"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookActionPerformedAuthor; + }; +}; + +export type AuthorSuspendedEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "author.suspended"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookActionPerformedAuthor; + }; +}; + +export type AuthorUpdatedEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "author.updated"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookAuthor; + }; +}; + +export type AuthorTrustLevelChangedEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "author.trust_level_changed"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookAuthor; + }; +}; + +export type AuthorActionEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "author.action"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookActionPerformedAuthor; + }; +}; + +export type QueueItemCompletedEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "queue_item.resolved"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookQueueItemEvent; + }; +}; + +export type QueueItemActionEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "queue_item.action"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookActionPerformed; + }; +}; + +export type QueueItemRejectedEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "queue_item.rejected"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookActionPerformed; + }; +}; + +export type QueueItemAllowedEvent = { + /** + * Stable event ID. Use this to dedupe retries. + */ + id: string; + /** + * The event type. + */ + type: "queue_item.allowed"; + api_version: "v2" | (string & {}); + /** + * ISO 8601 timestamp of when the event was emitted. + */ + created: string; + data: { + object: WebhookActionPerformed; + }; +}; + +export type WebhookActionPerformedAuthor = { + /** + * Moderation action ID + */ + id: string; + /** + * Customer-defined key identifying this action + */ + key: string | null; + /** + * Display name of the action + */ + name: string | null; + /** + * The value passed to the action when it ran + */ + value: string | null; + /** + * ISO 8601 timestamp of when the action was performed + */ + created_at: string; + /** + * The queue the item belongs to, if any + */ + queue?: { + id: string; + }; + /** + * The author the action was performed on + */ + author: WebhookAuthor; +}; + +export type WebhookAuthor = { + /** + * Author ID in Moderation API + */ + id: string; + /** + * The author's ID from your system + */ + external_id?: string | null; + /** + * URL of the author's profile picture + */ + profile_picture?: string | null; + /** + * URL of the author's external profile + */ + external_link?: string | null; + /** + * Author name or identifier + */ + name?: string | null; + /** + * Author email address + */ + email?: string | null; + /** + * The author's company or organization + */ + company?: string | null; + /** + * Timestamp when author first appeared + */ + first_seen: number; + /** + * Timestamp of last activity + */ + last_seen: number; + /** + * Timestamp of last incident + */ + last_incident?: number | null; + /** + * Current author status + */ + status: "enabled" | "suspended" | "blocked" | (string & {}); + trust_level: { + /** + * Author trust level (-1, 0, 1, 2, 3, or 4) + */ + level: number; + /** + * True if the trust level was set manually by a moderator + */ + manual: boolean; + }; + /** + * Block or suspension details, if applicable. Null if the author is enabled. + */ + block: { + /** + * The timestamp until which they are blocked if the author is suspended. + */ + until?: number | null; + /** + * The moderators reason why the author was blocked or suspended. + */ + reason?: string | null; + } | null; + /** + * Risk assessment details, if available. + */ + risk_evaluation: { + /** + * Calculated risk level based on more than 10 behavioral signals. + */ + risk_level?: number | null; + } | null; + metrics: { + /** + * Total pieces of content + */ + total_content: number; + /** + * Number of flagged content pieces + */ + flagged_content: number; + /** + * Average sentiment score of content (-1 to 1). Requires a sentiment model in your project. + */ + average_sentiment?: number | null; + }; + /** + * Additional metadata provided by your system. We recommend including any relevant information that may assist in the moderation process. + */ + metadata: { + /** + * Whether the author's email is verified + */ + email_verified?: boolean | null; + /** + * Whether the author's phone number is verified + */ + phone_verified?: boolean | null; + /** + * Whether the author's identity is verified + */ + identity_verified?: boolean | null; + /** + * Whether the author is a paying customer + */ + is_paying_customer?: boolean | null; + [key: string]: unknown; + }; +}; + +export type WebhookQueueItemEvent = { + item: WebhookQueueItem; + author?: WebhookAuthor; + queue?: { + id: string; + }; +}; + +export type WebhookActionPerformed = { + /** + * Moderation action ID + */ + id: string; + /** + * Customer-defined key identifying this action + */ + key: string | null; + /** + * Display name of the action + */ + name: string | null; + /** + * The value passed to the action when it ran + */ + value: string | null; + /** + * ISO 8601 timestamp of when the action was performed + */ + created_at: string; + /** + * The queue the item belongs to, if any + */ + queue?: { + id: string; + }; + /** + * The content item the action was performed on, if any + */ + item?: WebhookQueueItem; + /** + * The author the action was performed on, if any + */ + author?: WebhookAuthor; +}; + +export type WebhookQueueItem = { + /** + * Content ID from your system + */ + id: string; + /** + * Whether the content was flagged by moderation + */ + flagged: boolean | null; + /** + * Moderation labels applied to the content + */ + labels: Array<{ + /** + * The label name + */ + label: string; + /** + * Confidence score between 0 and 1 + */ + score: number; + /** + * True if the label was applied manually by a moderator + */ + manual?: boolean; + /** + * Whether this label crossed its flagging threshold + */ + flagged?: boolean; + /** + * Whether this label crossed its threshold under a shadowed policy — reported for visibility, never counted toward a flagging decision + */ + shadow_flagged?: boolean; + matches?: Array<{ + /** + * The matched substring + */ + match: string; + /** + * Match confidence between 0 and 1 + */ + probability: number; + /** + * [start, end] character offsets in the source text + */ + span: [number, number]; + mask?: string | null; + reasons?: Array; + signals?: { + brand_impersonation: { + brand: string; + method: string; + } | null; + has_suspicious_characters: boolean; + is_link_shortener: boolean; + domain_age_days: number | null; + has_email_setup: boolean | null; + redirect_count: number | null; + final_url: string | null; + bot_protection: boolean | null; + is_reported: boolean; + }; + entity_type?: string; + scripts?: Array; + skeleton?: string; + }>; + }> | null; + /** + * For object content, the field keys (e.g. "bio") that triggered a flag. Empty when no fields were flagged or the content is not an object. + */ + flagged_fields: Array; + /** + * Detected ISO language code, if available + */ + language: string | null; + /** + * The original content payload + */ + content: { + type: "text"; + /** + * The content text + */ + text: string; + } | { + type: "image"; + /** + * A public URL of the image content. Either url or data must be provided. + */ + url?: string; + /** + * Base64-encoded image data. Either url or data must be provided. Note: base64 images are not stored and will not appear in the review queue. + */ + data?: string; + } | { + type: "video"; + /** + * A public URL of the video content + */ + url: string; + } | { + type: "audio"; + /** + * The URL of the audio content + */ + url: string; + } | { + type: "object"; + /** + * Values in the object. Can be mixed content types. + */ + data: Record; + }; + /** + * ISO 8601 timestamp of when the content was submitted + */ + timestamp: string; + /** + * Customer-supplied metadata attached to the content (same shape as the moderation API `metadata` input). + */ + metadata: Record | null; + /** + * The recommendation you submitted via the moderation API `clientAction` input, if any. + */ + client_action: { + /** + * Your recommendation for the content: allow, review, or reject. + */ + action: "review" | "allow" | "reject" | (string & {}); + /** + * How your recommendation combines with ours. Defaults to 'escalate', which only applies it when stricter than ours; 'override' replaces ours outright. + */ + behavior?: "override" | "escalate" | (string & {}); + /** + * Where your recommendation came from, e.g. "banned-ip". + */ + source?: string; + /** + * A human-readable explanation for your recommendation. + */ + reason?: string; + } | null; + /** + * Conversation grouping ID, if any + */ + conversation_id: string | null; + /** + * External author ID (the customer's identifier, not Moderation API's internal id) + */ + author_id: string | null; + /** + * The channel the content was submitted to, identified by your customer-defined channel key. + */ + channel_key: string | null; + /** + * High-level content type (e.g. message, post, comment). Defaults to the channel's configured content type but can be overridden per request via the moderation API `type` field. + */ + meta_type: "profile" | "message" | "post" | "comment" | "event" | "product" | "review" | "voice" | "other" | (string & {}) | null; +}; + +/** Every webhook event type this SDK version knows. */ +export type WebhookEventType = "author.blocked" | "author.unblocked" | "author.suspended" | "author.updated" | "author.trust_level_changed" | "author.action" | "queue_item.resolved" | "queue_item.action" | "queue_item.rejected" | "queue_item.allowed"; + +/** + * A webhook event as delivered. Switch on `type` to narrow `data.object`. + * + * Events added to the API after this SDK version are delivered too, and + * reach the `default` branch of the switch. Read them as + * `UnknownWebhookEvent`. + */ +export type WebhookEvent = AuthorBlockedEvent | AuthorUnblockedEvent | AuthorSuspendedEvent | AuthorUpdatedEvent | AuthorTrustLevelChangedEvent | AuthorActionEvent | QueueItemCompletedEvent | QueueItemActionEvent | QueueItemRejectedEvent | QueueItemAllowedEvent; + +/** An event of a type this SDK version does not know. */ +export type UnknownWebhookEvent = { + id: string; + type: string; + api_version: string; + created: string; + data: { object: Record }; +}; + +/** Return true if this SDK version knows the event's type. */ +export function isKnownWebhookEvent( + event: WebhookEvent | UnknownWebhookEvent, +): event is WebhookEvent { + return (WEBHOOK_EVENT_TYPES as readonly string[]).includes(event.type); +} + +/** Every webhook event type this SDK version knows. */ +export const WEBHOOK_EVENT_TYPES: readonly WebhookEventType[] = [ + "author.blocked", + "author.unblocked", + "author.suspended", + "author.updated", + "author.trust_level_changed", + "author.action", + "queue_item.resolved", + "queue_item.action", + "queue_item.rejected", + "queue_item.allowed", +]; diff --git a/src/webhook-signature.ts b/src/webhook-signature.ts index 504d9e5..9483d8e 100644 --- a/src/webhook-signature.ts +++ b/src/webhook-signature.ts @@ -9,29 +9,12 @@ * generated. */ -export const WEBHOOK_SIGNATURE_HEADER = "modapi-signature"; -export const WEBHOOK_SECRET_ENV_VAR = "MODAPI_WEBHOOK_SECRET"; +import type { WebhookEvent } from "./webhook-events.js"; -export type WebhookEventType = - | "author.action" - | "author.blocked" - | "author.suspended" - | "author.trust_level_changed" - | "author.unblocked" - | "author.updated" - | "queue_item.action" - | "queue_item.allowed" - | "queue_item.rejected" - | "queue_item.resolved"; +export * from "./webhook-events.js"; -/** A webhook event as delivered: the parsed JSON body. */ -export type WebhookEvent = { - id: string; - type: WebhookEventType; - api_version: string; - created: string; - data: { object: Record } & Record; -}; +export const WEBHOOK_SIGNATURE_HEADER = "modapi-signature"; +export const WEBHOOK_SECRET_ENV_VAR = "MODAPI_WEBHOOK_SECRET"; /** The signature is missing or does not match the payload. */ export class WebhookSignatureError extends Error { @@ -57,6 +40,9 @@ const encoder = new TextEncoder(); * Verify a delivery and return the event. `secret` defaults to the * MODAPI_WEBHOOK_SECRET environment variable. Throws `WebhookSignatureError` * if the signature does not match. + * + * The payload is not validated against a schema: an event type or field added + * to the API later is returned as delivered, never rejected. */ export async function validateWebhook({ request,