@convertrilo/sdk 0.0.10 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,8 @@ The `examples/` directory contains starter scripts for the main integration path
33
33
  - `node-url-to-s3.ts` - encode a public URL and upload the result to S3/S3-compatible storage
34
34
  - `google-drive-byo-token.ts` - upload output to Google Drive using customer OAuth tokens from your app
35
35
  - `folder-ingest-s3.ts` - queue one encode job per video in an S3 prefix
36
+ - `idempotency.ts` - safely retry `createJob` and `createJobsBulk`
37
+ - `webhook-receiver-hmac.ts` - verify managed webhook HMAC signatures from a Node receiver
36
38
 
37
39
  Local SDK smoke tests use `.env`, but published SDK users should provide credentials through their
38
40
  own server environment. Do not put Convertrilo API keys or customer storage tokens in frontend code.
@@ -41,6 +43,38 @@ For a complete server-to-server walkthrough covering URL, S3, folder ingest, Goo
41
43
  OAuth tokens, polling, and webhooks, see
42
44
  [`docs/API-INTEGRATION-GUIDE.md`](docs/API-INTEGRATION-GUIDE.md).
43
45
 
46
+ ## Idempotent Job Creation
47
+
48
+ Use an idempotency key when retrying create calls from your backend. Reusing the same key with the
49
+ same body returns the original response instead of creating duplicate jobs.
50
+
51
+ ```ts
52
+ const job = await client.createJob({
53
+ externalId: "upload-123",
54
+ metadata: { customerId: "cus_123" },
55
+ codec: "h264",
56
+ resolution: "1080p",
57
+ fps: 30,
58
+ }, {
59
+ idempotencyKey: "job-upload-123",
60
+ });
61
+
62
+ const batch = await client.createJobsBulk({
63
+ jobs: [
64
+ {
65
+ externalId: "batch-42:clip-1",
66
+ codec: "h264",
67
+ resolution: "1080p",
68
+ fps: 30,
69
+ sourceS3: { bucket: "source", key: "clip-1.mp4" },
70
+ },
71
+ ],
72
+ settings: { confirm: true },
73
+ }, {
74
+ idempotencyKey: "bulk-batch-42",
75
+ });
76
+ ```
77
+
44
78
  ## URL Source To CDN Output
45
79
 
46
80
  ```ts
@@ -35,8 +35,13 @@ export declare class ConvertriloClient {
35
35
  reserveTokens(body: paths["/tokens/reserve"]["post"]["requestBody"]["content"]["application/json"]): Promise<unknown>;
36
36
  releaseTokens(body: paths["/tokens/release"]["post"]["requestBody"]["content"]["application/json"]): Promise<unknown>;
37
37
  deductTokens(body: paths["/tokens/deduct"]["post"]["requestBody"]["content"]["application/json"]): Promise<unknown>;
38
- createJob(body: paths["/jobs"]["post"]["requestBody"]["content"]["application/json"]): Promise<{
38
+ createJob(body: paths["/jobs"]["post"]["requestBody"]["content"]["application/json"], options?: RequestOptions): Promise<{
39
39
  jobId: string;
40
+ externalId?: string | null;
41
+ metadata?: {
42
+ [key: string]: unknown;
43
+ } | null;
44
+ status?: string;
40
45
  upload: {
41
46
  url?: string;
42
47
  key?: string;
@@ -47,6 +52,8 @@ export declare class ConvertriloClient {
47
52
  };
48
53
  estimate?: {
49
54
  neu?: number;
55
+ totalNeu?: number;
56
+ reserved?: number;
50
57
  };
51
58
  }>;
52
59
  probeDuration(id: string): Promise<{
@@ -68,19 +75,40 @@ export declare class ConvertriloClient {
68
75
  cancelJob(id: string): Promise<unknown>;
69
76
  jobStatus(id: string): Promise<{
70
77
  id?: string;
78
+ externalId?: string | null;
79
+ metadata?: {
80
+ [key: string]: unknown;
81
+ } | null;
71
82
  status?: string;
83
+ ingest?: string | null;
84
+ output?: {
85
+ key?: string;
86
+ publicUrl?: string;
87
+ } | null;
72
88
  downloadUrl?: string | null;
73
89
  encoder?: string | null;
74
90
  pct?: number | null;
91
+ createdAt?: string;
92
+ startedAt?: string | null;
93
+ finishedAt?: string | null;
94
+ failureMessage?: string | null;
75
95
  }>;
76
- createJobsBulk(body: paths["/jobs/bulk"]["post"]["requestBody"]["content"]["application/json"]): Promise<{
96
+ createJobsBulk(body: paths["/jobs/bulk"]["post"]["requestBody"]["content"]["application/json"], options?: RequestOptions): Promise<{
77
97
  totalJobs?: number;
78
98
  totalEstimatedNeu?: number;
99
+ reserved?: number;
79
100
  jobs?: {
80
101
  index?: number;
81
102
  jobId?: string;
103
+ externalId?: string | null;
104
+ metadata?: {
105
+ [key: string]: unknown;
106
+ } | null;
82
107
  status?: string;
83
108
  error?: string;
109
+ estimate?: {
110
+ neu?: number;
111
+ };
84
112
  }[];
85
113
  }>;
86
114
  bulkStatus(ids: string[]): Promise<{
package/dist/src/index.js CHANGED
@@ -68,10 +68,13 @@ export class ConvertriloClient {
68
68
  });
69
69
  }
70
70
  // Jobs
71
- async createJob(body) {
71
+ async createJob(body, options = {}) {
72
72
  return this.request(`/jobs`, {
73
73
  method: "POST",
74
74
  body: JSON.stringify(body),
75
+ headers: options.idempotencyKey
76
+ ? { "Idempotency-Key": options.idempotencyKey }
77
+ : undefined,
75
78
  });
76
79
  }
77
80
  async probeDuration(id) {
@@ -96,10 +99,13 @@ export class ConvertriloClient {
96
99
  return this.request(`/jobs/${id}/status`);
97
100
  }
98
101
  // Bulk Jobs
99
- async createJobsBulk(body) {
102
+ async createJobsBulk(body, options = {}) {
100
103
  return this.request(`/jobs/bulk`, {
101
104
  method: "POST",
102
105
  body: JSON.stringify(body),
106
+ headers: options.idempotencyKey
107
+ ? { "Idempotency-Key": options.idempotencyKey }
108
+ : undefined,
103
109
  });
104
110
  }
105
111
  async bulkStatus(ids) {
@@ -204,12 +204,15 @@ export interface paths {
204
204
  put?: never;
205
205
  /**
206
206
  * Create a new encode job
207
- * @description Create a job for upload ingest (returns a presigned PUT) or direct URL/S3 ingest.
207
+ * @description Create a job for upload ingest (returns a presigned PUT) or direct URL/S3 ingest. Send `Idempotency-Key` when retrying from your backend to avoid duplicate jobs.
208
208
  */
209
209
  post: {
210
210
  parameters: {
211
211
  query?: never;
212
- header?: never;
212
+ header?: {
213
+ /** @description Optional key for safely retrying job creation requests. Reusing the same key with the same request body replays the original response; reusing it with a different body returns 409. */
214
+ "Idempotency-Key"?: components["parameters"]["IdempotencyKey"];
215
+ };
213
216
  path?: never;
214
217
  cookie?: never;
215
218
  };
@@ -773,11 +776,17 @@ export interface paths {
773
776
  };
774
777
  get?: never;
775
778
  put?: never;
776
- /** Bulk create jobs */
779
+ /**
780
+ * Bulk create jobs
781
+ * @description Send `Idempotency-Key` when retrying from your backend to avoid duplicate bulk batches, duplicate token reservations, or duplicate queue entries.
782
+ */
777
783
  post: {
778
784
  parameters: {
779
785
  query?: never;
780
- header?: never;
786
+ header?: {
787
+ /** @description Optional key for safely retrying job creation requests. Reusing the same key with the same request body replays the original response; reusing it with a different body returns 409. */
788
+ "Idempotency-Key"?: components["parameters"]["IdempotencyKey"];
789
+ };
781
790
  path?: never;
782
791
  cookie?: never;
783
792
  };
@@ -1505,6 +1514,12 @@ export interface components {
1505
1514
  amount: number;
1506
1515
  };
1507
1516
  JobCreateRequest: {
1517
+ /** @description Your stable job identifier for reconciliation in status responses and webhooks. */
1518
+ externalId?: string;
1519
+ /** @description Integration-owned JSON object returned in status responses and webhooks. */
1520
+ metadata?: {
1521
+ [key: string]: unknown;
1522
+ };
1508
1523
  /** @enum {string} */
1509
1524
  codec: "h264" | "h265" | "av1";
1510
1525
  /** @enum {string} */
@@ -1559,6 +1574,12 @@ export interface components {
1559
1574
  };
1560
1575
  JobCreateResponse: {
1561
1576
  jobId: string;
1577
+ externalId?: string | null;
1578
+ metadata?: {
1579
+ [key: string]: unknown;
1580
+ } | null;
1581
+ /** @description Present when the job is auto-confirmed and queued. */
1582
+ status?: string;
1562
1583
  upload: {
1563
1584
  url?: string;
1564
1585
  key?: string;
@@ -1569,6 +1590,8 @@ export interface components {
1569
1590
  };
1570
1591
  estimate?: {
1571
1592
  neu?: number;
1593
+ totalNeu?: number;
1594
+ reserved?: number;
1572
1595
  };
1573
1596
  };
1574
1597
  ProbeDurationResponse: {
@@ -1619,13 +1642,39 @@ export interface components {
1619
1642
  };
1620
1643
  StatusResponse: {
1621
1644
  id?: string;
1645
+ externalId?: string | null;
1646
+ metadata?: {
1647
+ [key: string]: unknown;
1648
+ } | null;
1622
1649
  status?: string;
1650
+ ingest?: string | null;
1651
+ output?: {
1652
+ key?: string;
1653
+ publicUrl?: string;
1654
+ } | null;
1623
1655
  downloadUrl?: string | null;
1624
1656
  encoder?: string | null;
1625
1657
  pct?: number | null;
1658
+ /** Format: date-time */
1659
+ createdAt?: string;
1660
+ /** Format: date-time */
1661
+ startedAt?: string | null;
1662
+ /** Format: date-time */
1663
+ finishedAt?: string | null;
1664
+ failureMessage?: string | null;
1626
1665
  };
1627
1666
  ErrorResponse: {
1628
- message?: string;
1667
+ /**
1668
+ * @description Stable machine-readable error code. Branch on this instead of parsing `message`.
1669
+ * @enum {string}
1670
+ */
1671
+ code: "invalid_api_key" | "api_key_expired" | "user_not_found" | "account_banned" | "missing_required_scope" | "invalid_request" | "invalid_body" | "invalid_url" | "url_ingest_disabled" | "url_host_blocked" | "signed_url_required" | "host_not_resolvable" | "private_ip_blocked" | "missing_content_length" | "file_too_large" | "unsupported_content_type" | "probe_failed" | "insufficient_tokens" | "idempotency_conflict" | "idempotency_in_progress" | "not_found" | "forbidden" | "deprecated_dropbox" | "google_drive_not_connected" | "dropbox_not_connected" | "no_video_files_found" | "webhook_limit_exceeded" | "webhook_not_found" | "no_updates_provided" | "output_not_available";
1672
+ message: string;
1673
+ /** @description Validation details for `invalid_body`. */
1674
+ issues?: unknown;
1675
+ requiredScope?: string;
1676
+ required?: number;
1677
+ available?: number;
1629
1678
  };
1630
1679
  BulkCreateRequest: {
1631
1680
  jobs: components["schemas"]["JobCreateRequest"][];
@@ -1648,12 +1697,20 @@ export interface components {
1648
1697
  BulkCreateResponse: {
1649
1698
  totalJobs?: number;
1650
1699
  totalEstimatedNeu?: number;
1700
+ reserved?: number;
1651
1701
  jobs?: {
1652
1702
  index?: number;
1653
1703
  /** Format: uuid */
1654
1704
  jobId?: string;
1705
+ externalId?: string | null;
1706
+ metadata?: {
1707
+ [key: string]: unknown;
1708
+ } | null;
1655
1709
  status?: string;
1656
1710
  error?: string;
1711
+ estimate?: {
1712
+ neu?: number;
1713
+ };
1657
1714
  }[];
1658
1715
  };
1659
1716
  BulkStatusResponse: {
@@ -46,6 +46,43 @@ const client = new ConvertriloClient({
46
46
 
47
47
  SDK source and examples: https://github.com/serkandrgn/convertrilo-js
48
48
 
49
+ ## Idempotency
50
+
51
+ When your backend retries `POST /jobs`, `POST /jobs/bulk`, `POST /ondemand/encode`, or
52
+ `POST /ondemand/ingest/folder`, send an idempotency key. The API replays the original response for
53
+ the same key and body, and returns `409` if the key is reused with a different body.
54
+
55
+ ```ts
56
+ const job = await client.createJob({
57
+ externalId: "upload-123",
58
+ metadata: { customerId: "cus_123" },
59
+ codec: "h264",
60
+ resolution: "1080p",
61
+ fps: 30,
62
+ }, {
63
+ idempotencyKey: "job-upload-123",
64
+ });
65
+
66
+ const batch = await client.createJobsBulk({
67
+ jobs: [
68
+ {
69
+ externalId: "batch-42:clip-1",
70
+ codec: "h264",
71
+ resolution: "1080p",
72
+ fps: 30,
73
+ sourceS3: { bucket: "source", key: "clip-1.mp4" },
74
+ },
75
+ ],
76
+ settings: { confirm: true },
77
+ }, {
78
+ idempotencyKey: "bulk-batch-42",
79
+ });
80
+ ```
81
+
82
+ API errors include a stable `code` field plus a human-readable `message`. Branch on
83
+ `code` in your backend, for example `idempotency_conflict`, `insufficient_tokens`,
84
+ or `missing_required_scope`.
85
+
49
86
  ## Flow 1: URL Source To CDN Output
50
87
 
51
88
  Use this when the source video is already available over HTTP(S), and you want Convertrilo to return a signed CDN download URL.
@@ -0,0 +1,60 @@
1
+ import { ConvertriloClient } from "@convertrilo/sdk";
2
+
3
+ const apiKey = process.env.CONVERTRILO_API_KEY;
4
+ if (!apiKey) throw new Error("Set CONVERTRILO_API_KEY");
5
+
6
+ const client = new ConvertriloClient({
7
+ baseUrl: process.env.CONVERTRILO_API_URL || "https://api.convertrilo.com",
8
+ apiKey,
9
+ });
10
+
11
+ async function main() {
12
+ const externalId = `example-upload-${Date.now()}`;
13
+
14
+ const job = await client.createJob(
15
+ {
16
+ externalId,
17
+ metadata: {
18
+ customerId: "cus_123",
19
+ workflow: "sdk-idempotency-example",
20
+ },
21
+ codec: "h264",
22
+ resolution: "1080p",
23
+ fps: 30,
24
+ },
25
+ {
26
+ idempotencyKey: `job-${externalId}`,
27
+ },
28
+ );
29
+
30
+ console.log(`Created or replayed job ${job.jobId}`);
31
+
32
+ const batch = await client.createJobsBulk(
33
+ {
34
+ jobs: [
35
+ {
36
+ externalId: `${externalId}:clip-1`,
37
+ metadata: { customerId: "cus_123" },
38
+ codec: "h264",
39
+ resolution: "1080p",
40
+ fps: 30,
41
+ sourceS3: {
42
+ bucket: "customer-source-bucket",
43
+ key: "incoming/clip-1.mp4",
44
+ },
45
+ },
46
+ ],
47
+ settings: { dryRun: true },
48
+ },
49
+ {
50
+ idempotencyKey: `bulk-${externalId}`,
51
+ },
52
+ );
53
+
54
+ console.log(`Created or replayed bulk response with ${batch.totalJobs} job(s)`);
55
+ }
56
+
57
+ main().catch((error) => {
58
+ console.error(error);
59
+ process.exit(1);
60
+ });
@@ -0,0 +1,54 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import http from "node:http";
3
+
4
+ const secret = process.env.CONVERTRILO_WEBHOOK_SECRET;
5
+ if (!secret) throw new Error("Set CONVERTRILO_WEBHOOK_SECRET");
6
+
7
+ function verifySignature(rawBody: Buffer, signature: string) {
8
+ const expected = createHmac("sha256", secret!).update(rawBody).digest("hex");
9
+ const expectedBuffer = Buffer.from(expected, "hex");
10
+ const signatureBuffer = Buffer.from(signature, "hex");
11
+
12
+ return (
13
+ expectedBuffer.length === signatureBuffer.length &&
14
+ timingSafeEqual(expectedBuffer, signatureBuffer)
15
+ );
16
+ }
17
+
18
+ const server = http.createServer((request, response) => {
19
+ if (request.method !== "POST" || request.url !== "/webhooks/convertrilo") {
20
+ response.writeHead(404);
21
+ response.end("Not found");
22
+ return;
23
+ }
24
+
25
+ const chunks: Buffer[] = [];
26
+
27
+ request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
28
+ request.on("end", () => {
29
+ const rawBody = Buffer.concat(chunks);
30
+ const signature = String(request.headers["x-webhook-signature"] || "");
31
+
32
+ if (!signature || !verifySignature(rawBody, signature)) {
33
+ response.writeHead(401);
34
+ response.end("Invalid signature");
35
+ return;
36
+ }
37
+
38
+ const payload = JSON.parse(rawBody.toString("utf8"));
39
+ console.log("Verified Convertrilo webhook", {
40
+ event: payload.event,
41
+ jobId: payload.data?.jobId,
42
+ status: payload.data?.status,
43
+ externalId: payload.data?.externalId,
44
+ });
45
+
46
+ response.writeHead(204);
47
+ response.end();
48
+ });
49
+ });
50
+
51
+ const port = Number(process.env.PORT || 3000);
52
+ server.listen(port, () => {
53
+ console.log(`Webhook receiver listening on http://localhost:${port}/webhooks/convertrilo`);
54
+ });
package/openapi.yaml CHANGED
@@ -81,6 +81,14 @@ components:
81
81
  type: object
82
82
  required: [codec, resolution, fps]
83
83
  properties:
84
+ externalId:
85
+ type: string
86
+ maxLength: 255
87
+ description: Your stable job identifier for reconciliation in status responses and webhooks.
88
+ metadata:
89
+ type: object
90
+ additionalProperties: true
91
+ description: Integration-owned JSON object returned in status responses and webhooks.
84
92
  codec:
85
93
  type: string
86
94
  enum: [h264, h265, av1]
@@ -136,6 +144,14 @@ components:
136
144
  required: [jobId, upload, output]
137
145
  properties:
138
146
  jobId: { type: string }
147
+ externalId: { type: string, nullable: true }
148
+ metadata:
149
+ type: object
150
+ additionalProperties: true
151
+ nullable: true
152
+ status:
153
+ type: string
154
+ description: Present when the job is auto-confirmed and queued.
139
155
  upload:
140
156
  nullable: true
141
157
  type: object
@@ -151,6 +167,8 @@ components:
151
167
  type: object
152
168
  properties:
153
169
  neu: { type: number }
170
+ totalNeu: { type: number }
171
+ reserved: { type: number }
154
172
  ProbeDurationResponse:
155
173
  type: object
156
174
  properties:
@@ -196,14 +214,70 @@ components:
196
214
  type: object
197
215
  properties:
198
216
  id: { type: string }
217
+ externalId: { type: string, nullable: true }
218
+ metadata:
219
+ type: object
220
+ additionalProperties: true
221
+ nullable: true
199
222
  status: { type: string }
223
+ ingest: { type: string, nullable: true }
224
+ output:
225
+ nullable: true
226
+ type: object
227
+ properties:
228
+ key: { type: string }
229
+ publicUrl: { type: string }
200
230
  downloadUrl: { type: string, nullable: true }
201
231
  encoder: { type: string, nullable: true }
202
232
  pct: { type: number, nullable: true }
233
+ createdAt: { type: string, format: date-time }
234
+ startedAt: { type: string, format: date-time, nullable: true }
235
+ finishedAt: { type: string, format: date-time, nullable: true }
236
+ failureMessage: { type: string, nullable: true }
203
237
  ErrorResponse:
204
238
  type: object
239
+ required: [code, message]
205
240
  properties:
241
+ code:
242
+ type: string
243
+ description: Stable machine-readable error code. Branch on this instead of parsing `message`.
244
+ enum:
245
+ - invalid_api_key
246
+ - api_key_expired
247
+ - user_not_found
248
+ - account_banned
249
+ - missing_required_scope
250
+ - invalid_request
251
+ - invalid_body
252
+ - invalid_url
253
+ - url_ingest_disabled
254
+ - url_host_blocked
255
+ - signed_url_required
256
+ - host_not_resolvable
257
+ - private_ip_blocked
258
+ - missing_content_length
259
+ - file_too_large
260
+ - unsupported_content_type
261
+ - probe_failed
262
+ - insufficient_tokens
263
+ - idempotency_conflict
264
+ - idempotency_in_progress
265
+ - not_found
266
+ - forbidden
267
+ - deprecated_dropbox
268
+ - google_drive_not_connected
269
+ - dropbox_not_connected
270
+ - no_video_files_found
271
+ - webhook_limit_exceeded
272
+ - webhook_not_found
273
+ - no_updates_provided
274
+ - output_not_available
206
275
  message: { type: string }
276
+ issues:
277
+ description: Validation details for `invalid_body`.
278
+ requiredScope: { type: string }
279
+ required: { type: number }
280
+ available: { type: number }
207
281
 
208
282
  # Bulk Jobs
209
283
  BulkCreateRequest:
@@ -230,6 +304,7 @@ components:
230
304
  properties:
231
305
  totalJobs: { type: integer }
232
306
  totalEstimatedNeu: { type: number }
307
+ reserved: { type: number }
233
308
  jobs:
234
309
  type: array
235
310
  items:
@@ -237,8 +312,17 @@ components:
237
312
  properties:
238
313
  index: { type: integer }
239
314
  jobId: { type: string, format: uuid }
315
+ externalId: { type: string, nullable: true }
316
+ metadata:
317
+ type: object
318
+ additionalProperties: true
319
+ nullable: true
240
320
  status: { type: string }
241
321
  error: { type: string }
322
+ estimate:
323
+ type: object
324
+ properties:
325
+ neu: { type: number }
242
326
  BulkStatusResponse:
243
327
  type: object
244
328
  properties:
@@ -714,7 +798,9 @@ paths:
714
798
  post:
715
799
  security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
716
800
  summary: Create a new encode job
717
- description: Create a job for upload ingest (returns a presigned PUT) or direct URL/S3 ingest.
801
+ description: Create a job for upload ingest (returns a presigned PUT) or direct URL/S3 ingest. Send `Idempotency-Key` when retrying from your backend to avoid duplicate jobs.
802
+ parameters:
803
+ - $ref: "#/components/parameters/IdempotencyKey"
718
804
  requestBody:
719
805
  required: true
720
806
  content:
@@ -993,6 +1079,9 @@ paths:
993
1079
  post:
994
1080
  security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }]
995
1081
  summary: Bulk create jobs
1082
+ description: Send `Idempotency-Key` when retrying from your backend to avoid duplicate bulk batches, duplicate token reservations, or duplicate queue entries.
1083
+ parameters:
1084
+ - $ref: "#/components/parameters/IdempotencyKey"
996
1085
  requestBody:
997
1086
  required: true
998
1087
  content:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@convertrilo/sdk",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "TypeScript client for the Convertrilo video encoding API",
5
5
  "private": false,
6
6
  "license": "MIT",