Integrate PackProof into your systems with our REST API and TypeScript SDK. Automate media uploads, evidence sealing, and real-time event notifications.
The PackProof API lets you programmatically manage orders, upload media, seal evidence, and receive real-time webhook notifications. Available on the Enterprise plan.
Navigate to Settings → API Keys in your PackProof dashboard. Click Create API Key, give it a name, and copy the key. The full key is shown only once.
curl https://app.packproof.com/api/v1/organization \
-H "X-API-Key: pk_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"npm install @packproof/sdkimport { PackProof } from '@packproof/sdk';
const client = new PackProof({
apiKey: process.env.PACKPROOF_API_KEY,
});
const org = await client.organization.get();
console.log(org.name);A Swagger UI explorer is available at /api/v1/docs. The OpenAPI 3.0 spec can be fetched at /api/v1/docs/openapi.json.
Include your API key in the X-API-Key header on every request.
X-API-Key: pk_live_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4API keys are scoped to a single organization. Each key is hashed with SHA-256 before storage — PackProof never stores your raw key. If you lose it, revoke and create a new one.
All responses use a consistent envelope:
// Single resource
{ "data": { "id": "order_123", ... } }
// List
{ "data": [...], "pagination": { "total": 150, "limit": 20, "offset": 0, "hasMore": true } }
// Error
{ "error": { "code": "VALIDATION_ERROR", "message": "Order name is required" } }/organizationGet organization info for the authenticated API key
// Response
{
"data": {
"id": "org_abc123",
"name": "Acme Logistics",
"slug": "acme-logistics",
"industry": "Maritime",
"planTier": "enterprise",
"createdAt": "2025-01-15T10:30:00.000Z"
}
}Orders represent jobs, shipments, or work units that contain media items.
/ordersList orders (paginated, filterable)
/orders/:idGet a single order
/ordersCreate a new order
| Parameter | Type | Required | Description |
|---|---|---|---|
status | string | No | Filter by "active" or "completed" |
search | string | No | Search order names |
limit | integer | No | Results per page (max 100, default 20) |
offset | integer | No | Pagination offset (default 0) |
// Response
{
"data": [
{
"id": "order_123",
"orderNumber": "Ship Alpha",
"status": "in-progress",
"photoCount": 24,
"createdAt": "2025-03-01T09:15:00.000Z"
}
],
"pagination": { "total": 45, "limit": 20, "offset": 0, "hasMore": true }
}| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Name for the order |
clientId | string | No | Associate with an existing client |
curl -X POST https://app.packproof.com/api/v1/orders \
-H "X-API-Key: pk_live_..." \
-H "Content-Type: application/json" \
-d '{"name": "Container Ship Beta", "clientId": "client_456"}'Media items are photos and videos attached to orders. The API accepts both image and video uploads through the same endpoint.
/mediaList media items (filterable by orderId)
/media/:idGet media item metadata
/mediaUpload a media file (multipart/form-data)
/media/:idDelete a media item permanently
Note: Photos and videos are unified under a single /media resource. The server detects which is which from the upload's MIME type; on response objects, image-specific fields like metadata.file.width/.height are present only for images.
| Category | Formats | MIME Types |
|---|---|---|
| Images | JPEG, PNG, WebP, HEIC | image/jpeg, image/png, image/webp, image/heic |
| Videos | MP4, WebM, MOV, M4V | video/mp4, video/webm, video/quicktime, video/x-m4v |
Maximum file size: 50 MB for API uploads. Other PackProof workflows (camera capture, file picker) support larger uploads via chunked transfer.
Use multipart/form-data. The server detects whether the file is a photo or video from the MIME type and file extension.
| Parameter | Type | Required | Description |
|---|---|---|---|
file | binary | Yes | The image or video file |
orderId | string | Yes | Order to attach the media to |
fileName | string | No | Custom filename (auto-generated if omitted) |
curl -X POST https://app.packproof.com/api/v1/media \
-H "X-API-Key: pk_live_..." \
-F "file=@/path/to/photo.jpg" \
-F "orderId=order_789"curl -X POST https://app.packproof.com/api/v1/media \
-H "X-API-Key: pk_live_..." \
-F "file=@/path/to/inspection.mp4" \
-F "orderId=order_789"When fileName is omitted, the server generates a name in the format photo-20250310-143000-a1b2c3d4.jpg or video-20250310-143000-a1b2c3d4.mp4.
// API upload response (image)
{
"data": {
"id": "media_abc",
"orderId": "order_789",
"organizationId": "org_abc123",
"fileName": "photo-20250310-143000-a1b2c3d4.jpg",
"fullUrl": "https://...",
"thumbnailUrl": "https://...",
"posterFileId": null,
"metadata": {
"timestamp": "2025-03-10T14:30:00.000Z",
"file": {
"size": 2048576,
"width": 4032,
"height": 3024,
"mimeType": "image/jpeg"
},
"device": { "ipAddress": "api" }
},
"uploadedAt": "2025-03-10T14:30:05.000Z"
}
}Media captured through the PackProof app may also include metadata.location (GPS coordinates) and device details. These fields are not present for API uploads.
posterFileId is always null for API uploads. Poster thumbnails are only generated for media uploaded through the PackProof UI. metadata.file.width and metadata.file.height are populated for images only. metadata.location is present only when the capturing device provides GPS coordinates (typically device-captured media, not API uploads).
curl -X DELETE https://app.packproof.com/api/v1/media/media_abc \
-H "X-API-Key: pk_live_..."// Response
{ "data": { "id": "media_abc", "deleted": true } }Clients represent repeat customers or business entities that place orders.
/clientsList all clients
/clients/:idGet a single client
/clientsCreate a new client
/clients/:idUpdate a client
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Client name (must be unique within org) |
notes | string | No | Internal notes (max 2000 chars) |
curl -X POST https://app.packproof.com/api/v1/clients \
-H "X-API-Key: pk_live_..." \
-H "Content-Type: application/json" \
-d '{"name": "Pacific Freight Co.", "notes": "Primary contact: John Smith"}'Sealing provides cryptographic proof that a media item existed in its exact form at a specific point in time. Each seal includes SHA-512 content hashes, an HMAC-SHA-512 digital signature, and a unique certificate ID.
/certificationsList all seals
/certifications/:certIdGet a seal by certificate ID
/certificationsSeal a media item
/certifications/batchBatch seal multiple media items
/certifications/verify/:certId(no auth)Public verification (anyone can verify)
Note: API paths use /certifications for backward compatibility. The user-facing term is "sealing."
curl -X POST https://app.packproof.com/api/v1/certifications \
-H "X-API-Key: pk_live_..." \
-H "Content-Type: application/json" \
-d '{"photoId": "photo_abc"}'// Response (201 Created)
{
"data": {
"certificationId": "ATT-2025-A1B2C3D4E5F6A7B8",
"photoId": "photo_abc",
"organizationId": "org_abc123",
"certifiedBy": "api",
"certifiedAt": "2025-03-10T15:00:00.000Z",
"contentHash": "e3b0c44298fc1c149afbf4c8996fb924...",
"metadataHash": "9f86d081884c7d659a2feaa0c55ad015a3...",
"signature": "a7ffc6f8bf1ed76651c14756a061d662...",
"photoMetadataSnapshot": {
"fileName": "photo-20250310-143000-a1b2c3d4.jpg",
"capturedAt": "2025-03-10T14:30:00.000Z",
"location": { "latitude": -33.8688, "longitude": 151.2093 },
"device": { "platform": "api" }
},
"verificationCount": 0
}
}Sealing a media item that is already sealed returns 409 Conflict. Certificate IDs follow the format ATT-YYYY-XXXXXXXXXXXXXXXX (16 uppercase hex characters).
curl -X POST https://app.packproof.com/api/v1/certifications/batch \
-H "X-API-Key: pk_live_..." \
-H "Content-Type: application/json" \
-d '{"photoIds": ["photo_1", "photo_2", "photo_3"]}'// Response
{
"data": {
"certified": 2,
"alreadyCertified": 1,
"total": 3,
"results": [
{ "photoId": "photo_1", "certificationId": "ATT-2025-A1B2C3D4E5F6A7B8", "success": true },
{ "photoId": "photo_2", "certificationId": "ATT-2025-E5F6A7B8C9D0E1F2", "success": true },
{ "photoId": "photo_3", "certificationId": "ATT-2025-C9D0E1F2A3B4C5D6", "success": true }
]
}
}Items that are already sealed are included in results with their existing certificate ID and success: true. Items are processed in parallel batches of 5.
No authentication required. Anyone with the certificate ID can verify that the evidence has not been altered.
curl https://app.packproof.com/api/v1/certifications/verify/ATT-2025-A1B2C3D4E5F6A7B8{
"data": {
"verified": true,
"certificationId": "ATT-2025-A1B2C3D4E5F6A7B8",
"certifiedAt": "2025-03-10T15:00:00.000Z",
"organization": "Acme Logistics",
"signatureValid": true,
"contentHash": "e3b0c44298fc1c149afbf4c8996fb924...",
"verificationCount": 4,
"photoMetadata": {
"fileName": "photo-20250310-143000-a1b2c3d4.jpg",
"capturedAt": "2025-03-10T14:30:00.000Z",
"location": {
"latitude": -33.8688,
"longitude": 151.2093,
"address": "Sydney Harbour"
},
"device": {
"platform": "iPhone",
"model": "iPhone 15 Pro",
"osName": "iOS",
"osVersion": "17.4"
},
"timezone": "AEST"
}
}
}| Field | Description |
|---|---|
verified | Whether the HMAC-SHA-512 signature is valid |
signatureValid | Same as verified (explicit signature check result) |
contentHash | SHA-512 hash of the original file bytes at time of sealing |
verificationCount | Number of times this certificate has been verified |
photoMetadata | Frozen snapshot of metadata captured at time of sealing |
Receive real-time HTTP POST notifications when events occur. Register a URL, subscribe to events, and PackProof will send signed payloads to your server.
/webhooksList webhooks
/webhooksCreate a webhook (returns signing secret)
/webhooks/:idGet a webhook
/webhooks/:idUpdate a webhook
/webhooks/:idDelete a webhook
/webhooks/:id/deliveriesView delivery history
/webhooks/:id/testSend a test event
| Event | Trigger |
|---|---|
media.uploaded | A media item is uploaded (UI or API) |
media.deleted | A media item is deleted (UI or API) |
certification.created | A media item is sealed |
order.created | A new order is created |
order.completed | An order is archived/completed |
curl -X POST https://app.packproof.com/api/v1/webhooks \
-H "X-API-Key: pk_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhooks/packproof",
"events": ["media.uploaded", "certification.created"],
"description": "Warehouse sync"
}'Important: The signing secret is returned only in the creation response. Store it securely. If you lose it, delete the webhook and create a new one.
Update a webhook's URL, events, description, or active status. All fields are optional.
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | No | New webhook URL |
events | string[] | No | Updated event subscriptions |
description | string | No | Updated description |
isActive | boolean | No | Enable or disable the webhook |
Every delivery includes an X-PackProof-Signature header. Verify it using HMAC-SHA256:
import crypto from 'crypto';
// Verify signature
const signature = req.headers['x-packproof-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.PACKPROOF_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}| Header | Description |
|---|---|
X-PackProof-Signature | sha256=<HMAC-SHA256 hex digest> |
X-PackProof-Event | Event type (e.g., media.uploaded) |
X-PackProof-Delivery-ID | Unique delivery ID for deduplication |
If your server doesn't return 2xx within 10 seconds, PackProof retries:
| Attempt | Delay | Total Elapsed |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 1 minute | ~1 min |
| 3 | 10 minutes | ~11 min |
The @packproof/sdk package provides a type-safe client for the PackProof API. Works with Node.js 18+, Deno, Bun, and any runtime with native fetch.
npm install @packproof/sdk
# or
yarn add @packproof/sdk
# or
pnpm add @packproof/sdkimport { PackProof } from '@packproof/sdk';
const client = new PackProof({
apiKey: process.env.PACKPROOF_API_KEY, // Required
baseUrl: 'https://app.packproof.com', // Optional (default)
maxRetries: 3, // Optional (default: 3)
});| Resource | Methods |
|---|---|
client.organization | get() |
client.orders | list(), get(), create() |
client.photos | list(), get(), upload(), delete() |
client.clients | list(), get(), create(), update() |
client.certifications | list(), get(), create(), batch(), verify() |
client.webhooks | list(), get(), create(), update(), delete(), deliveries(), test() |
// List active orders
const { data: orders, pagination } = await client.orders.list({
status: 'active',
limit: 50,
});
// Upload a photo
import fs from 'fs';
const photo = await client.photos.upload({
orderId: 'order_123',
file: fs.readFileSync('/path/to/photo.jpg'),
fileName: 'inspection-001.jpg',
});
// Upload a video
const video = await client.photos.upload({
orderId: 'order_123',
file: fs.readFileSync('/path/to/inspection.mp4'),
});
// Seal a media item
const cert = await client.certifications.create({
photoId: photo.id,
});
console.log(cert.certificationId); // "ATT-2025-A1B2C3D4E5F6A7B8"
// Batch seal
const result = await client.certifications.batch({
photoIds: ['photo_1', 'photo_2', 'photo_3'],
});
// Verify (public, no auth needed)
const verification = await client.certifications.verify('ATT-2025-A1B2C3D4E5F6A7B8');
// Create a webhook
const webhook = await client.webhooks.create({
url: 'https://your-server.com/webhooks',
events: ['media.uploaded', 'certification.created'],
});
console.log(webhook.secret); // Save this!import { PackProof, PackProofError } from '@packproof/sdk';
try {
await client.photos.get('nonexistent');
} catch (err) {
if (err instanceof PackProofError) {
console.error(`API Error [${err.code}]: ${err.message}`);
console.error(`Status: ${err.statusCode}`);
}
}The SDK automatically retries 5xx errors with exponential backoff (500ms, 1s, 2s). Client errors (4xx) are never retried.
async function getAllOrders(client) {
const allOrders = [];
let offset = 0;
const limit = 100;
while (true) {
const { data, pagination } = await client.orders.list({ limit, offset });
allOrders.push(...data);
if (!pagination.hasMore) break;
offset += limit;
}
return allOrders;
}All API errors follow the same structure:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Order name is required"
}
}| Code | HTTP | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid API key |
FORBIDDEN | 403 | Valid key but lacks access (e.g., non-Enterprise plan) |
NOT_FOUND | 404 | Resource does not exist or belongs to another org |
VALIDATION_ERROR | 400 | Missing required fields or invalid data |
CONFLICT | 409 | Resource already exists (e.g., media already sealed) |
RATE_LIMIT_EXCEEDED | 429 | Too many requests (1,000/hour per key) |
INTERNAL_ERROR | 500 | Server error (safe to retry) |
| Error | Retry? | Strategy |
|---|---|---|
| 401/403/404/400/409 | No | Fix the request |
| 429 Rate Limited | Yes | Wait for X-RateLimit-Reset, then retry |
| 500 Internal Error | Yes | Retry with exponential backoff |
| Network error | Yes | Retry with exponential backoff |
Every API response includes rate limit headers:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 997
X-RateLimit-Reset: 1710100800| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests per 1-hour window (1,000) |
X-RateLimit-Remaining | Remaining requests in the current window |
X-RateLimit-Reset | Unix timestamp when the window resets |
When the limit is exceeded, the API returns 429 with the RATE_LIMIT_EXCEEDED error code. Check the X-RateLimit-Reset header and wait before retrying.
import { PackProof } from '@packproof/sdk';
const client = new PackProof({ apiKey: process.env.PACKPROOF_API_KEY });
async function syncOrdersToCRM() {
let offset = 0;
const limit = 100;
while (true) {
const { data: orders, pagination } = await client.orders.list({ limit, offset });
for (const order of orders) {
await upsertOrderInCRM({
externalId: order.id,
name: order.orderNumber,
status: order.status,
photoCount: order.photoCount,
});
}
if (!pagination.hasMore) break;
offset += limit;
}
}
// Run every 15 minutes via cron
syncOrdersToCRM();import { PackProof } from '@packproof/sdk';
import fs from 'fs';
const client = new PackProof({ apiKey: process.env.PACKPROOF_API_KEY });
async function uploadAndSeal(orderId, files) {
const photoIds = [];
// Upload all media
for (const file of files) {
const photo = await client.photos.upload({
orderId,
file: fs.readFileSync(file.path),
fileName: file.name,
});
photoIds.push(photo.id);
console.log(`Uploaded: ${photo.id}`);
}
// Batch seal
const result = await client.certifications.batch({ photoIds });
console.log(`Sealed: ${result.certified}/${result.total}`);
return result;
}import express from 'express';
import crypto from 'crypto';
const app = express();
app.post('/webhooks/packproof',
express.raw({ type: 'application/json' }),
(req, res) => {
// Verify signature
const signature = req.headers['x-packproof-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.PACKPROOF_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
)) {
return res.status(401).send('Invalid signature');
}
const payload = JSON.parse(req.body.toString());
switch (payload.event) {
case 'media.uploaded':
// Handle new media upload...
break;
case 'certification.created':
// Handle sealed evidence...
break;
case 'order.completed':
// Handle completion...
break;
}
res.status(200).send('OK');
}
);
app.listen(3000);import requests
import os
BASE_URL = 'https://app.packproof.com/api/v1'
HEADERS = {'X-API-Key': os.environ['PACKPROOF_API_KEY']}
# List orders
response = requests.get(f'{BASE_URL}/orders', headers=HEADERS, params={'status': 'active'})
orders = response.json()['data']
for order in orders:
print(f"{order['orderNumber']}: {order['photoCount']} items")
# Upload a photo
with open('inspection.jpg', 'rb') as f:
response = requests.post(
f'{BASE_URL}/media',
headers=HEADERS,
files={'file': ('inspection.jpg', f, 'image/jpeg')},
data={'orderId': orders[0]['id']}
)
photo = response.json()['data']
# Upload a video
with open('walkthrough.mp4', 'rb') as f:
response = requests.post(
f'{BASE_URL}/media',
headers=HEADERS,
files={'file': ('walkthrough.mp4', f, 'video/mp4')},
data={'orderId': orders[0]['id']}
)
video = response.json()['data']
# Seal evidence
response = requests.post(
f'{BASE_URL}/certifications',
headers=HEADERS,
json={'photoId': photo['id']}
)
cert = response.json()['data']
print(f"Sealed: {cert['certificationId']}")# List active orders
curl -s https://app.packproof.com/api/v1/orders?status=active \
-H "X-API-Key: pk_live_..."
# Upload a photo
curl -X POST https://app.packproof.com/api/v1/media \
-H "X-API-Key: pk_live_..." \
-F "file=@/path/to/photo.jpg" \
-F "orderId=order_123"
# Upload a video
curl -X POST https://app.packproof.com/api/v1/media \
-H "X-API-Key: pk_live_..." \
-F "file=@/path/to/inspection.mp4" \
-F "orderId=order_123"
# Seal evidence
curl -X POST https://app.packproof.com/api/v1/certifications \
-H "X-API-Key: pk_live_..." \
-H "Content-Type: application/json" \
-d '{"photoId": "photo_abc"}'
# Verify (no auth needed)
curl https://app.packproof.com/api/v1/certifications/verify/ATT-2025-A1B2C3D4E5F6A7B8