API Documentation
Complete reference for integrating AI-powered audio transcription and content repurposing into your applications.
1.1.0Base URL https://api.repurposeapi.comAuthentication
All API requests (except /health) require authentication. Authenticate using either API Keys (recommended for developers) or JWT tokens (for web apps).
API Keys (Recommended)
For programmatic access and integrations, use API keys.
Getting Your API Key
- 1.Sign up at
https://repurposeapi.com/signup - 2.Navigate to Dashboard → API Keys
- 3.Click "Create New Key"
- 4.Copy and securely store your key (it won't be shown again)
Using API Keys
Include your API key in the Authorization header:
curl -X POST https://api.repurposeapi.com/v1/transcribe \
-H "Authorization: Bearer rp_live_xxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"file_url": "https://..."}'Key Formats
rp_test_*— Test mode (no billing, mock responses for development)rp_live_*— Production mode (real processing, counts toward quota)
JWT Tokens (Web Apps)
For browser-based applications, authenticate via Supabase Auth JWT tokens:
- 1.Sign up / Sign in via Supabase Auth (email/password, OAuth, etc.)
- 2.Get your access token from Supabase session
- 3.Include the token in all API requests
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Job-Based Async Pattern
All v1 endpoints follow an async job pattern:
- 1.Submit request → Receive
job_idimmediately - 2.Poll for status →
GET /v1/jobs/:idreturns current status - 3.Retrieve results → When
status: "completed",output_datacontains results
This pattern is ideal for long-running audio/video processing tasks.
Quickstart
Get your first API response in under 5 minutes.
Step 1 — Sign Up
Create a free account at https://repurposeapi.com/signup. No credit card required. You get $5 in free credits.
Step 2 — Get Your API Key
Navigate to Dashboard → API Keys and create a new key.
Step 3 — First Request
curl https://api.repurposeapi.com/v1/summaries \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"transcript_text": "In this episode, we discuss the future of artificial intelligence and machine learning. We explore how AI is revolutionizing healthcare, finance, and transportation. Our guest emphasizes the importance of ethical AI development and transparency."
}'{
"job_id": "550e8400-e29b-41d4-a716-446655440000"
}Step 4 — Get Results
Poll the job endpoint until completion:
curl https://api.repurposeapi.com/v1/jobs/550e8400-e29b-41d4-a716-446655440000 \ -H "Authorization: Bearer YOUR_API_KEY"
{
"job": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"output_data": {
"short": "This episode explores AI's impact on healthcare, finance, and transportation, emphasizing ethical development.",
"medium": "A comprehensive discussion on artificial intelligence and machine learning...",
"long": "An in-depth exploration of how artificial intelligence is transforming..."
}
}
}That's it! You've made your first API call. Explore the Core Endpoints to see what else you can build.
Endpoints
Health Check
{
"status": "ok",
"timestamp": "2025-12-15T10:30:00.000Z",
"service": "api"
}Upload Files
{
"upload_url": "https://<account>.r2.cloudflarestorage.com/<bucket>/uploads/...?X-Amz-Signature=...",
"download_url": "https://<account>.r2.cloudflarestorage.com/<bucket>/uploads/...?X-Amz-Signature=...",
"file_key": "uploads/user-id/1234567890-abc123.mp3"
}download_url is valid for 2 hours — long enough to upload and then create a job, which is the intended flow. It is not a permanent link: request a fresh one if you need to process the same file later, and treat it as a credential rather than something to log or share.Usage Flow
- 1.Request presigned URL from this endpoint
- 2.Upload file directly to
upload_urlvia PUT request - 3.Use
download_urlin subsequent job creation requests
Complete Upload Flow
# Step 1: Get presigned URL
curl -X POST https://api.repurposeapi.com/v1/uploads/presigned \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"filename": "podcast.mp3",
"content_type": "audio/mpeg",
"file_size": 12345678
}'
# Step 2: Upload file to presigned URL
curl -X PUT "https://...presigned-url..." \
-H "Content-Type: audio/mpeg" \
--data-binary "@podcast.mp3"
# Step 3: Use download_url in job creation (see Core Endpoints)Get Usage Information
{
"live_jobs_created": 5,
"live_jobs_completed": 4,
"live_minutes_used": 18.5,
"credit_balance_cents": 305,
"credit_balance_usd": "3.05",
"subscription_tier": "builder",
"subscription_status": "active",
"pending_plan": null,
"billing_period_end": "2026-02-28"
}| Field | Type | Description |
|---|---|---|
live_jobs_created | number | Live jobs created this billing period |
live_jobs_completed | number | Successfully completed live jobs |
live_minutes_used | number | Audio minutes processed this period (display only — not a quota) |
credit_balance_cents | integer | Available credit balance in cents. Computed: non-expired grants − usage − active reservations. |
credit_balance_usd | string | Same balance formatted as USD (e.g. "3.05") |
subscription_tier | string | Current plan: free, payg, builder, scale, or volume |
subscription_status | string | Stripe subscription status (e.g. active, past_due, canceled) |
pending_plan | string | null | Scheduled downgrade at end of billing period |
billing_period_end | string | null | ISO date of billing period end |
List All Jobs
{
"jobs": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"job_type": "transcribe",
"created_at": "2025-12-15T10:00:00.000Z"
}
]
}API Key Management
These endpoints are for programmatic key rotation and revocation. Your first API key must be created from the Dashboard (Dashboard → API Keys), since these endpoints require existing authentication.
List API Keys
{
"api_keys": [
{
"id": "a1b2c3d4-...",
"name": "Production Key",
"key_prefix": "rp_live_xxxx",
"environment": "live",
"project": "postclip",
"is_active": true,
"last_used_at": "2026-02-20T14:00:00.000Z",
"inserted_at": "2026-01-10T09:00:00.000Z"
}
]
}Create API Key
{
"api_key": {
"id": "a1b2c3d4-...",
"name": "CI Deploy Key",
"key_prefix": "rp_live_xxxx",
"environment": "live",
"project": "postclip",
"is_active": true,
"inserted_at": "2026-02-24T10:00:00.000Z"
},
"key": "rp_live_xxxxxxxxxxxxxxxxxxxxx",
"warning": "Save this key now - it won't be shown again!"
}key value is only returned once at creation time. Store it securely — it cannot be retrieved again.Revoke API Key
{
"status": "revoked",
"message": "API key has been successfully revoked"
}Analytics
403 response.Core Endpoints
All /v1 endpoints follow an async job pattern. Submit a request → receive a job_id → poll GET /v1/jobs/:id → retrieve results when status is "completed".
Get Job Status
{
"job": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"job_type": "podcast_to_blog",
"input_url": "https://<account>.r2.cloudflarestorage.com/<bucket>/uploads/...",
"output_data": { "...": "workflow result" },
"error_message": null,
"metadata": { "tone": "professional" },
"created_at": "2025-12-15T10:00:00.000Z",
"updated_at": "2025-12-15T10:00:30.000Z",
"usage": {
"endpoint": "/v1/workflows/podcast-to-blog",
"billable_unit": "audio_minute",
"quantity": 32.5,
"rate_cents": 6.0,
"charge_cents": 195.0,
"charge_usd": "1.95",
"credit_balance_cents": 305.0,
"credit_balance_usd": "3.05"
}
}
}| Field | Type | Description |
|---|---|---|
status | string | pending, processing, completed, failed |
output_data | object | null | Results when status is completed |
usage | object | null | Per-call billing summary — null for failed/pending jobs (see below) |
usage_reason | string (optional) | Reason usage is null when determinate — currently only "job_failed_not_billed" |
The `usage` block
Every job response carries a usage field describing how that call was priced. Designed so callers can budget against the API without separate billing lookups.
| Field | Type | Description |
|---|---|---|
endpoint | string | The endpoint that produced the charge |
billable_unit | string | per_call, audio_minute, or transcript_minute |
quantity | number | Units billed — 1 for per-call, minutes otherwise |
rate_cents | number | Rate per unit in cents |
charge_cents | number | Total charge in cents |
charge_usd | string | Same charge formatted for display ("1.95") |
credit_balance_cents | number | Remaining credit balance after this charge |
credit_balance_usd | string | Same balance formatted for display |
usage: null with no reason — the charge isn't determined yet. Failed jobs have usage: null with usage_reason: "job_failed_not_billed". Workflows chained from source_job_id ortranscript_text carry billable_unit: "transcript_minute" at the reduced rate.Get Job Transcript
Returns the transcript text, segments, language, and duration from any completed job whose output_data contains a transcript (e.g. transcribe, clip-finder, and any workflow that produced one). Cross-user lookups return 404.
{
"transcript": {
"text": "Full transcript text...",
"segments": [{ "start": 0.0, "end": 5.2, "text": "First segment..." }],
"language": "en",
"duration": 1800.5,
"source_job_id": "550e8400-e29b-41d4-a716-446655440000"
}
}| Status | Meaning |
|---|---|
| 200 | Job is completed and has a transcript |
| 404 | Job not found, or belongs to another user |
| 409 | Job exists but is not yet completed or has no transcript |
Transcribe Audio
job_id. Poll GET /v1/jobs/:id to check status and retrieve results.{
"transcript": "Full transcription text...",
"segments": [
{ "start": 0.0, "end": 5.2, "text": "Hello and welcome to the podcast." }
],
"language": "en",
"duration": 3600.5
}Generate Chapters
{
"chapters": [
{ "title": "Introduction", "description": "Overview of the episode topics" },
{ "title": "Main Discussion", "description": "Deep dive into the core subject" }
]
}Generate Summaries
{
"short": "One to two sentence summary...",
"medium": "One paragraph summary...",
"long": "Two to three paragraph detailed summary..."
}Clean Text
{
"cleaned_text": "In this episode, we discuss the three pillars of...",
"changes": {
"timestamps_removed": 5,
"repetitions_fixed": 12,
"music_artifacts_removed": 3,
"filler_words_removed": 47
},
"original_length": 1520,
"cleaned_length": 1345
}Detect Best Clips
{
"clips": [
{
"rank": 1,
"start": 145.2,
"end": 165.8,
"duration": 20.6,
"text": "This is the most important insight...",
"score": 0.92,
"reason": "High engagement moment discussing key insight with emotional impact"
}
]
}Remove Silences (Jumpcut)
Timestamps Only (render=false)
{
"segments": [
{"start": 0.0, "end": 12.5},
{"start": 15.2, "end": 45.8},
{"start": 48.1, "end": 120.0}
],
"original_duration": 120.0,
"edited_duration": 114.4,
"time_saved": 5.6
}With Rendered File (render=true)
{
"segments": [...],
"output_url": "https://<account>.r2.cloudflarestorage.com/<bucket>/processed/jumpcut-123.mp3",
"original_duration": 120.0,
"edited_duration": 114.4,
"time_saved": 5.6
}Workflows
Workflow endpoints combine multiple operations for convenience. Each workflow transcribes the audio first, then runs the specified processing pipeline.
Input Modes
Every workflow except clip-finder accepts the transcript source in one of three forms. Provide exactly one of file_url, source_job_id, or transcript_text.
| Field | When to use it | Behavior |
|---|---|---|
| file_url | You have an audio or video file to process from scratch. | The worker downloads the file and runs Whisper transcription before the workflow LLM step. |
| source_job_id | You already ran another job that produced a transcript (e.g. clip-finder, transcribe). | Reuses that transcript — no re-transcription, no audio download. Charged at the reduced transcript-minute rate. |
| transcript_text | You already have transcript text from outside the API. | Skips Whisper entirely. Charged at the reduced transcript-minute rate. |
source_job_id is scoped to the calling user. Looking up another user's job returns 404, not 403 — callers can't distinguish "doesn't exist" from "exists but belongs to someone else."Podcast to Blog
{
"transcript": "Full transcription...",
"summary": "This podcast discusses strategies for scaling...",
"chapters": [
{ "title": "Introduction", "start_time": "00:00", "summary": "Overview of scaling challenges" },
{ "title": "Hiring Strategy", "start_time": "05:30", "summary": "How to build the right team" }
],
"blog_draft": "# How to Scale Your Startup\n\n## Introduction\n\n...",
"language": "en",
"duration": 3600.5
}Clip Finder
target_duration × 0.75 to × 1.5, so the source must be at least target_duration × 0.75 — 15 seconds at the default 20s target. Shorter sources are rejected with 422 source_too_short before any transcription runs, so they cost no credits. Lower target_duration if you are working with short clips.num_clips exceeds your cap it is clamped automatically. Every paid plan also includes Advanced AI Ranking, a multi-pass semantic analysis that produces more differentiated clip scores.{
"transcript": "Full transcription...",
"segments": [{ "start": 0.0, "end": 5.2, "text": "First segment..." }],
"duration": 1800.5,
"language": "en",
"clips": [
{
"rank": 1, "start": 30.0, "end": 52.0, "duration": 22.0,
"text": "The clip transcript text...",
"score": 0.92,
"reason": "Strong hook with a quotable takeaway",
"social": {
"hook": "This changes everything about AI",
"twitter": { "text": "...", "hashtags": ["AI", "Podcasting"] },
"linkedin": { "text": "...", "hashtags": ["AIInsights"] },
"instagram": { "caption": "...", "hashtags": ["AI", "PodcastClips"] },
"tiktok": { "caption": "...", "hashtags": ["ai", "fyp"] }
}
}
],
"processing_time": {
"transcription": 45.2, "clip_detection": 8.1, "enrichment": 5.3, "total": 58.6
}
}POST /v1/clip-detect directly — it's faster since it skips transcription.Tweet Thread
{
"transcript": "Full transcript...",
"thread": [
{ "tweet_number": 1, "text": "1/ Hook tweet...", "char_count": 142 }
],
"thread_count": 7,
"topic": "One-line topic summary",
"processing_time": { "transcription": 12.5, "thread_generation": 3.2, "total": 15.7 }
}Meeting Notes
{
"transcript": "Full transcript...",
"summary": "Meeting summary...",
"action_items": [
{ "id": 1, "action": "Finalize spec", "owner": "Sarah", "priority": "high", "deadline_suggestion": "by Friday" }
],
"key_decisions": ["Launch date set for March 15"],
"main_topics": ["Roadmap", "Testing"],
"processing_time": { "transcription": 18.3, "extraction": 4.1, "total": 22.4 }
}Hook Lines
{
"transcript": "Full transcript...",
"hooks": [
{
"text": "Most people are productive for only 3 hours a day.",
"type": "statistic",
"source_timestamp": 45.0,
"why_it_works": "Specific number creates curiosity"
}
],
"processing_time": { "transcription": 10.2, "hook_extraction": 3.8, "total": 14.0 }
}Reference
Job Types
| Job Type | Description | Typical Duration |
|---|---|---|
transcribe | Audio/video → text transcription | 5-15 min (1hr audio) |
chapters | Generate chapter breakdowns from transcript | 1-2 min |
summarize | Generate short, medium, long summaries | 1-2 min |
social_captions | Generate platform-specific social media content | 1-2 min |
clean_text | Remove hallucinations and artifacts from transcript | 1-2 min |
clip_detect | Auto-detect best 15-30s clips for social sharing | 2-3 min |
jumpcut | Detect/remove silences from audio | 3-10 min |
podcast_to_blog | Full podcast processing pipeline | 10-20 min (1hr audio) |
tweet_thread | Media → Twitter/X thread | 2-5 min |
meeting_notes | Meeting recording → action items + summary | 3-6 min |
hook_lines | Media → scroll-stopping hook lines | 2-4 min |
newsletter_draft | Media → newsletter draft with sections | 3-7 min |
clip_finder | Full clip pipeline (transcribe + detect + social) | 5-15 min (1hr audio) |
Response Formats
Job Status Values
pending— Job created, waiting to startprocessing— Currently being processedcompleted— Successfully completedfailed— Processing failed (seeerror_message)
Polling for Results
- 1.Create job via POST endpoint
- 2.Poll
GET /v1/jobs/:idevery 2-5 seconds - 3.Check status until
completedorfailed - 4.Retrieve
output_datafrom completed job
Example Polling Logic
async function waitForJobCompletion(jobId, token) {
while (true) {
const response = await fetch(
`https://api.repurposeapi.com/v1/jobs/${jobId}`,
{ headers: { 'Authorization': `Bearer ${token}` } }
)
const { job } = await response.json()
if (job.status === 'completed') return job.output_data
if (job.status === 'failed') throw new Error(job.error_message)
// Wait 3 seconds before polling again
await new Promise(resolve => setTimeout(resolve, 3000))
}
}Error Handling
Standard HTTP status codes. All errors return consistent JSON:
{
"error": {
"code": "invalid_file_format",
"message": "Unsupported format. Use MP3, MP4, WAV, M4A, or FLAC."
}
}| Status | Meaning | Common Cause |
|---|---|---|
200 | OK | Successful request |
201 | Created | Job created successfully |
400 | Bad Request | Missing required fields or invalid format |
401 | Unauthorized | Invalid or missing API key |
402 | Payment Required | Insufficient credit balance — top up or upgrade |
404 | Not Found | Job or resource not found |
422 | Unprocessable | Validation errors, or a source too short to process |
429 | Rate Limited | Too many requests — retry with backoff |
500 | Server Error | Internal error — contact support |
Example Error Responses
{ "error": "Unauthorized" }{ "error": "filename and content_type are required" }{
"error": "source_too_short",
"message": "clip-finder needs at least 15.0s of audio to produce a 20s clip; this source is 9.4s. Try a longer recording, or lower `target_duration`.",
"duration_seconds": 9.4,
"minimum_seconds": 15.0,
"target_duration": 20
}{
"error": "insufficient_credits",
"available_cents": 42.0,
"required_cents": 195.0,
"top_up_url": "https://repurposeapi.com/pricing"
}{
"error": "Rate limit exceeded",
"scope": "project",
"message": "Maximum 60 requests per minute for this API key. Other keys on this account are unaffected.",
"retry_after": 60
}Every 429 response also includes a Retry-After header (seconds; currently always 60), matching the retry_after field in the body. A 429 from the job status limit has the same body shape; its message says it is the job status limit.
Rate Limits & Quotas
Rate Limiting
- 60 requests per minute per API key
- 180 requests per minute across all keys on an account
- Applies to all authenticated endpoints except job status polling
- Job status polling (
GET /v1/jobs/:id) has its own limit: 300 requests per minute per API key, 900 requests per minute across all keys on an account - Rolling 60-second window
Each key gets its own budget, so one busy integration cannot throttle another on the same account. The account-wide figure is the ceiling across every key together.
GET /v1/jobs/:id does not draw from the same budget as job submission, so polling no longer uses up the requests you need for submitting work. Only that route uses the job status limit — GET /v1/jobs, GET /v1/jobs/:id/transcript, GET /v1/usage and all POST requests use the general limit. Polling is not billed — billing and rate limiting are separate — but a tight polling loop will still reach the job status limit. Poll no faster than once per second per job.Response Headers
X-RateLimit-Limit: 60 X-RateLimit-Remaining: 57 X-RateLimit-Reset: 1639584000
These report whichever limit is currently binding. If the account ceiling is closer than the key's own limit, they describe the account.
They describe the limit of the route that was called. On a GET /v1/jobs/:id response they describe the job status limit, so do not use them to pace job submissions.
The rate limit headers and Retry-After are exposed via CORS, so cross-origin JavaScript in a browser can read them.
Projects
Give a key a project when you create it and its usage, spend and rate limit are tracked separately from your other keys — without a second account or a second invoice. Useful for running several apps, or an agency billing several clients, off one account.
POST /v1/api-keys
{ "name": "Acme production", "environment": "live", "project": "acme" }GET /v1/usage then returns a projects breakdown. Requests made with an unlabelled key are grouped under untagged.
{
"projects": [
{ "project": "acme", "jobs": 128, "spend_cents": 512.4, "spend_usd": "$5.12" },
{ "project": "globex", "jobs": 31, "spend_cents": 96.0, "spend_usd": "$0.96" },
{ "project": "untagged", "jobs": 2, "spend_cents": 4.0, "spend_usd": "$0.04" }
]
}Labels are normalised to lowercase, so Acme and acme are the same project.
Handling Rate Limits
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After') || 60
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
// Retry request
}Handling Insufficient Credits
if (response.status === 402) {
const data = await response.json()
console.log(`Need ${data.required_cents}¢, have ${data.available_cents}¢`)
console.log('Top up at:', data.top_up_url)
}GET /v1/usage to avoid hitting limits mid-workflow.What Counts as a Request
- Rate limit — every authenticated request counts. Polling
GET /v1/jobs/:iddoes not count against the general 60/min budget used for job creation; it has its own, larger limit of 300/min per API key. - Credits — only job creation consumes credits. Polling and webhook deliveries are free.
- Failed jobs are not billed — no credits are consumed
Supported Formats
Audio: MP3, WAV, M4A, AAC, OGG, FLAC, WMA, AIFF
Video: MP4, MOV, AVI, MKV, WEBM, FLV, M4V
Files are automatically converted to the optimal format for processing. Video files have their audio track extracted before transcription.
Pricing Plans
For current pricing tiers and details, see the Pricing Page.
| Plan | Credits | File Size | Concurrent Jobs | Max Clips | Clip Detection |
|---|---|---|---|---|---|
| Free | $5 one-time trial | 50MB | 2 | 3 | Basic |
| PAYG | Billed per call | 1GB | 5 | 5 | Multi-pass AI |
| Builder — $29/mo | $35/mo included | 1GB | 5 | 5 | Multi-pass AI |
| Scale — $99/mo | $125/mo included | 2GB | 10 | 10 | Multi-pass AI |
| Volume — custom | Negotiated | 4GB | 50 | 10 | Multi-pass AI |
Every plan is billed from the same per-endpoint rates — commitment plans discount the bundled credits, not the per-call price. Included credits roll over for 60 days and are consumed oldest-first.
Webhooks
Instead of polling for job completion, have the API POST to your endpoint when a job reaches a terminal state.
Webhooks are configured per API key. Each key is a project, so two integrations on one account deliver to two different endpoints, and disabling one leaves the other running. Available on PAYG, Builder and Scale.
Setup
curl -X PUT https://api.repurposeapi.com/v1/api-keys/$KEY_ID/webhook \
-H "Authorization: Bearer $YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-app.com/webhooks/repurpose"}'{
"webhook_url": "https://your-app.com/webhooks/repurpose",
"webhook_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}null for it and leaves the existing secret working.The URL must use https and must not resolve to a private or internal address — loopback, link-local, RFC1918 ranges, cloud metadata endpoints and internal hostnames are rejected with 422 invalid_webhook_url. This is checked when you configure the endpoint and again before every delivery, so a DNS record that changes later stops delivering rather than reaching somewhere private. For local development use a tunnel rather than localhost.
Webhook Payload
{
"event": "job.completed",
"delivery_id": "d7e41f8a-3c2b-4e19-9a7d-1f8e2c5b6a40",
"job_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "completed",
"job_type": "transcribe",
"user_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"environment": "live",
"source_job_id": null,
"project": "postclip",
"error_message": null,
"occurred_at": "2026-09-22T10:00:00Z",
"data": { "output_data": {} }
}| Field | Notes |
|---|---|
delivery_id | Stable across retries of the same delivery — dedupe on this |
environment | live or test. Sandbox jobs deliver too |
project | The project label on the key that created the job, or null |
error_message | Populated on job.failed, null otherwise |
occurred_at | When the job reached its terminal state — not when this attempt was sent |
occurred_at is deliberately not called timestamp: it does not move between retry attempts, which is what lets a retry send a byte-identical body. The send time is the t in the signature header.
output_data would push the payload past 1 MB, data is replaced by {"truncated": true} and every other field is unchanged. Fetch the result with GET /v1/jobs/:id when you see it.Headers
X-Repurpose-Event: job.completed X-Repurpose-Delivery: d7e41f8a-3c2b-4e19-9a7d-1f8e2c5b6a40 X-Repurpose-Signature: t=1758535200,v1=5257a869e7ecebeda32affa62cdca3fa...
Verifying Webhooks
v1 is HMAC-SHA256(secret, "{t}.{raw_request_body}"), hex-encoded lowercase. Two things matter: sign the raw request body exactly as received — parsing the JSON and re-serialising it will not produce the same bytes — and reject old timestamps, or a captured delivery replays forever.
const crypto = require('crypto')
// rawBody must be the unparsed request body. In Express:
// app.use('/webhooks', express.raw({ type: 'application/json' }))
function verifyWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=', 2))
)
const timestamp = Number(parts.t)
const received = parts.v1
if (!timestamp || !received) return false
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
return false // replayed or badly skewed clock
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
// Both are hex of a SHA-256 digest, so lengths always match.
// timingSafeEqual throws on unequal lengths, hence the checks above.
return crypto.timingSafeEqual(
Buffer.from(received, 'hex'),
Buffer.from(expected, 'hex')
)
}Delivery & Retries
X-Repurpose-Delivery, which is stable across every attempt. Respond 2xx as soon as you have durably accepted the payload and do the work afterwards — a slow handler becomes a retry.| Your response | What happens |
|---|---|
2xx | Delivered. No retry |
3xx | Permanent failure. Redirects are not followed — configure the final URL |
408, 429 | Retried |
Other 4xx | Permanent failure. A 404 will not become a 200 later |
5xx, timeout, connection error | Retried |
Retries use exponential backoff over up to 8 attempts, spanning roughly 24 hours, after which the delivery is marked exhausted. The receive timeout is 10 seconds. There is no ordering guarantee — two jobs finishing at the same moment may deliver in either order; use occurred_at if order matters.
Event Types
| Event | Description |
|---|---|
job.completed | Job finished successfully |
job.failed | Job failed; see error_message |
Only terminal states deliver. There is no job.started event — poll the job if you need to observe the transition into processing. A job delivers if the API key that created it has a webhook configured; jobs created from the dashboard have no key attached and never deliver.
SDKs & Libraries
Official SDKs (Coming Soon)
- JavaScript/TypeScript — npm package
- Python — PyPI package
REST Client Examples
# cURL — Create transcription job
curl -X POST https://api.repurposeapi.com/v1/transcribe \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"file_url": "<download_url from /v1/uploads/presigned>",
"model": "base"
}'// JavaScript (Fetch)
const response = await fetch('https://api.repurposeapi.com/v1/workflows/podcast-to-blog', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_url: downloadUrl, // from /v1/uploads/presigned
title: 'My Podcast Episode',
tone: 'professional'
})
})
const { job } = await response.json()
console.log('Job created:', job.id)import requests
import time
# Create a transcription job
response = requests.post(
'https://api.repurposeapi.com/v1/transcribe',
headers={'Authorization': f'Bearer {api_key}'},
json={
'file_url': download_url, # from /v1/uploads/presigned
'model': 'base'
}
)
job_id = response.json()['job_id']
print(f"Job created: {job_id}")
# Poll for completion
while True:
job_response = requests.get(
f'https://api.repurposeapi.com/v1/jobs/{job_id}',
headers={'Authorization': f'Bearer {api_key}'}
)
job = job_response.json()['job']
if job['status'] == 'completed':
print("Transcript:", job['output_data']['transcript'])
break
elif job['status'] == 'failed':
print("Error:", job['error_message'])
break
time.sleep(3)require 'net/http'
require 'json'
require 'uri'
# Create a summaries job
uri = URI('https://api.repurposeapi.com/v1/summaries')
request = Net::HTTP::Post.new(uri)
request['Authorization'] = "Bearer #{api_key}"
request['Content-Type'] = 'application/json'
request.body = {
transcript_text: 'Sample podcast transcript...'
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
job_id = JSON.parse(response.body)['job_id']
puts "Job created: #{job_id}"Complete Example: Podcast to Blog
Here's a complete workflow from upload to blog draft:
// 1. Get presigned upload URL
const uploadRes = await fetch('https://api.repurposeapi.com/v1/uploads/presigned', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filename: 'podcast.mp3',
content_type: 'audio/mpeg'
})
})
const { upload_url, download_url } = await uploadRes.json()
// 2. Upload file to storage
await fetch(upload_url, {
method: 'PUT',
headers: { 'Content-Type': 'audio/mpeg' },
body: audioFile
})
// 3. Create podcast-to-blog job
const jobRes = await fetch('https://api.repurposeapi.com/v1/workflows/podcast-to-blog', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_url: download_url,
title: 'How to Scale Your Startup',
tone: 'professional'
})
})
const { job } = await jobRes.json()
// 4. Poll for completion
let completed = false
while (!completed) {
await new Promise(resolve => setTimeout(resolve, 3000))
const statusRes = await fetch(`https://api.repurposeapi.com/v1/jobs/${job.id}`, {
headers: { 'Authorization': `Bearer ${token}` }
})
const { job: updatedJob } = await statusRes.json()
if (updatedJob.status === 'completed') {
console.log('Transcript:', updatedJob.output_data.transcript)
console.log('Blog Draft:', updatedJob.output_data.blog_draft)
completed = true
} else if (updatedJob.status === 'failed') {
throw new Error(updatedJob.error_message)
}
}Support & Feedback
- Documentation:
https://repurposeapi.com/docs - Issues:
https://github.com/repurposeapi/issues - Email:
support@repurposeapi.com - Discord: Coming soon
For the full release history, see the Changelog.
Generate Social Captions
{ "captions": { "twitter": { "text": "Just learned the #1 mistake founders make when scaling...", "hashtags": ["#startup", "#founders", "#scaleup"], "char_count": 142 }, "linkedin": { "text": "In today's episode, we explore the critical mistakes...", "hashtags": ["#leadership", "#business", "#entrepreneurship"], "word_count": 180 }, "instagram": { "caption": "🚀 What if I told you most founders fail at scaling for ONE reason?", "hashtags": ["#podcast", "#business", "#motivation", "#startup"] }, "tiktok": { "caption": "Wait for the plot twist at 0:45 👀", "hashtags": ["#podcast", "#viral", "#fyp", "#business"] } } }