Documentation
Your first image search.
Call Pluko over HTTP from your backend: create a search job, follow its progress, and read image URLs and metadata. No SDK required.
Quick start
1Create a project and an API key
In your workspace, create a project, then generate an API key with the
searchandreadscopes. Copy it when it’s shown: the full key is displayed only once.2Set your environment variables
Keep the key on your server. Use your deployment’s address when hosted.
.envPLUKO_URL=https://your-pluko-host PLUKO_API_KEY=YOUR_API_KEY
3Create your first search
POST /v1/images/searchcurl -X POST "$PLUKO_URL/v1/images/search" \ -H "Authorization: Bearer $PLUKO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "concrete architecture", "count": "15", "min_width": 1000 }'Note
A202response means the job was accepted, not that images are ready. Use the returnedjob_idwith the status and results endpoints below.
Authentication
Send Authorization: Bearer YOUR_API_KEY on every public API request. Keep keys on your server, never in browser code, mobile apps, URLs or logs.
| Scope | Allows |
|---|---|
search | Create, cancel and retry jobs. |
read | Read job state, results and usage. |
Tip
Jobs and statuses
A search is a background job. Poll GET /v1/jobs/JOB_ID with backoff, or use a webhook. A job is always in one of these states:
| Status | Meaning |
|---|---|
queued | Accepted and waiting for the worker. |
running | Pages are being fetched; results appear as they are found. |
complete | Every query found what it asked for. |
partial | Finished with fewer images than requested. Check each query’s reason. |
cancelled | Stopped before it finished. Results found so far are kept. |
Each query in a job reports a reason when it stops:
| Reason | What happened |
|---|---|
requested_count_reached | All requested images were found. |
source_exhausted_or_no_new_matches | The source ran out of new matching images. |
daily_page_quota | Stopped at the project’s daily page limit. It resets at 00:00 UTC. |
provider_failed | The image source kept failing for this query. |
key_revoked_or_expired | The API key was revoked or expired during the job. |
user_cancelled | The job was cancelled. |
Reading results
Read GET /v1/jobs/JOB_ID/results?limit=100&after=0. A page holds up to 100 images. Pass next_cursor as after to read the next page.
When next_cursor is null you have reached the end of the results available right now. If the job is still running, wait and continue from poll_after to fetch only new results without reading earlier rows again.
Retries and idempotency
Add a unique Idempotency-Key header when you create a job. Retrying with the same key and identical input returns the original job instead of starting a second one. Reusing the key with different input returns 409 IDEMPOTENCY_CONFLICT.
Create a search
/v1/images/searchScope: searchStarts a job and returns 202 straight away. Send either query (with an optional count) or a queries array for a batch.
| Field | Type | Description |
|---|---|---|
| query | string | The search text, up to 200 characters. |
| count | string | Images wanted per query, as a positive decimal string. Default "15". No product cap; the source may have fewer. |
| queries | array | A batch of { query, count } objects. Queries must be unique. |
| min_width | integer | Minimum width in pixels, 0 to 20000. Default 800. |
| exclude_domains | string[] | Plain domains to skip, such as example.com. |
| watermark_policy | string | prefer_clean (default) or any. A hint from titles and domains, not a guarantee. |
| safe_search, image_type, layout, size, color | string | Optional filters. |
curl -X POST "$PLUKO_URL/v1/images/search" \
-H "Authorization: Bearer $PLUKO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "concrete architecture",
"count": "15",
"min_width": 1000
}'{
"job_id": "job_example",
"reused": false,
"status_url": "/v1/jobs/job_example",
"results_url": "/v1/jobs/job_example/results",
"request_id": "req_example"
}Get a job
/v1/jobs/{job_id}Scope: readReturns the job’s status, per-query progress, the filters it ran with, and expires: when its results will be removed.
curl "$PLUKO_URL/v1/jobs/JOB_ID" \ -H "Authorization: Bearer $PLUKO_API_KEY"
{
"job": {
"id": "job_example",
"status": "running",
"requested": "15",
"returned": 6
},
"request_id": "req_example"
}Read results
/v1/jobs/{job_id}/resultsScope: read| Field | Type | Description |
|---|---|---|
| limit | integer | 1 to 100. Default 50. |
| after | integer | Cursor from the previous page’s next_cursor, or poll_after while the job runs. Default 0. |
curl "$PLUKO_URL/v1/jobs/JOB_ID/results?limit=100&after=0" \ -H "Authorization: Bearer $PLUKO_API_KEY"
{
"images": [
{
"id": "img_example",
"title": "Concrete architecture",
"image_url": "https://source.example/image.jpg",
"thumbnail_url": "https://source.example/preview.jpg",
"source_url": "https://source.example/photo",
"width": 1600,
"height": 1200,
"dimensions_verified": false,
"license_status": "unknown"
}
],
"next_cursor": null,
"request_id": "req_example"
}List jobs
/v1/jobsScope: readLists the key’s project jobs, newest first.
| Field | Type | Description |
|---|---|---|
| limit | integer | 1 to 100. Default 50. |
| status | string | queued, running, complete, partial or cancelled. |
| before | integer | Pass next_cursor from the previous page. |
curl "$PLUKO_URL/v1/jobs?status=running" \ -H "Authorization: Bearer $PLUKO_API_KEY"
Cancel and retry
/v1/jobs/{job_id}/cancelScope: search/v1/jobs/{job_id}/retryScope: searchCancel stops a queued or running job. A job that already finished returns 409 JOB_FINISHED and keeps its status. Retry creates a new job from the original request and returns its job_id.
curl -X POST "$PLUKO_URL/v1/jobs/JOB_ID/cancel" \ -H "Authorization: Bearer $PLUKO_API_KEY"
Send feedback
/v1/feedbackScope: search| Field | Type | Description |
|---|---|---|
| job_idrequired | string | The job the result belongs to. |
| result_idrequired | string | The image’s id. |
| reasonrequired | string | not_relevant, watermark, broken or other. |
| note | string | Up to 1000 characters. |
curl -X POST "$PLUKO_URL/v1/feedback" \
-H "Authorization: Bearer $PLUKO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"job_id": "JOB_ID",
"result_id": "IMAGE_ID",
"reason": "not_relevant",
"note": "The result does not match the query."
}'List your reports, newest first, with GET /v1/feedback (read scope). Each one includes an image with its title, URLs and source domain, or null once retention has removed the result.
Usage
/v1/usageScope: readToday’s provider pages used, the project’s daily limit, pages remaining, when it resets (00:00 UTC) and the last week. A page is one request to the image source and returns up to 35 images.
curl "$PLUKO_URL/v1/usage" \ -H "Authorization: Bearer $PLUKO_API_KEY"
Service status
/v1/statusNo API key neededReports whether the API, the search worker and the image source are working. It needs no key and is also shown on the status page.
curl "$PLUKO_URL/v1/status"
Batch searches
Send several unique queries in one job, each with its own count. JSON bodies are limited to 64 KiB, so split very large batches across jobs. There’s no image-count cap, but source availability and your project’s daily limit still apply.
{
"queries": [
{
"query": "mountain landscape",
"count": "15"
},
{
"query": "wireless headphones",
"count": "30"
}
],
"min_width": 800,
"watermark_policy": "prefer_clean"
}Filters
Narrow a search with optional fields next to query. Omit a field, or send any, to leave it unfiltered. Filters are passed to the image source on a best-effort basis, so some results may not match exactly.
| Field | Values |
|---|---|
safe_search | moderatestrict |
image_type | anyphotoclipartline_drawinganimated_giftransparent |
layout | anysquarewidetall |
size | anysmallmediumlargewallpaper |
color | anycolorblack_whiteredorangeyellowgreentealbluepurplepinkbrownblackgraywhite |
Important
safe_search defaults to moderate and can’t be switched off. An invalid value returns 400 INVALID_FILTER.{
"query": "city skyline",
"count": "20",
"image_type": "photo",
"layout": "wide",
"size": "large",
"safe_search": "strict"
}Webhooks
Skip polling: add a webhook to a project (Projects → Webhook) and Pluko sends a signed POST to your https endpoint when a job becomes complete, partial or cancelled. Fetch the results with the usual results endpoint using the event’s job_id.
{
"id": "evt_example",
"type": "job.finished",
"created": 1760000000000,
"data": {
"job_id": "job_example",
"project_id": "prj_example",
"status": "complete",
"requested": "20",
"returned": 20,
"queries": [
{
"query": "city skyline",
"requested": "20",
"found": 20,
"status": "complete",
"reason": "requested_count_reached"
}
],
"results_url": "/v1/jobs/job_example/results"
}
}Each request carries Pluko-Event, Pluko-Delivery (unique per event; use it to ignore duplicates) and Pluko-Signature as t=<ms>,v1=<hex>. v1 is the HMAC-SHA256 of "<t>.<raw body>" using your signing secret, which is shown once when you save the webhook. Verify it, and reject timestamps older than five minutes.
import {createHmac,timingSafeEqual} from 'node:crypto';
// rawBody must be the exact request body string, before any JSON parsing.
export function verify(secret,header,rawBody){
const parts=Object.fromEntries(header.split(',').map(part=>part.split('=')));
if(Math.abs(Date.now()-Number(parts.t))>300000)return false; // reject replays
const expected=createHmac('sha256',secret).update(`${parts.t}.${rawBody}`).digest('hex');
const given=Buffer.from(parts.v1||''),wanted=Buffer.from(expected);
return given.length===wanted.length&&timingSafeEqual(given,wanted);
}| Rule | Detail |
|---|---|
| Answer quickly | Any 2xx status within 8 seconds. |
| Retries | Up to 5, after about 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. Retry a failed one from the dashboard. |
| Endpoint | https on port 443 with a public domain name. Private and internal addresses are refused. |
| Ordering | At least once, not strictly ordered. Treat the job status as the source of truth. |
Errors and limits
Errors share one shape. Keep request_id and job_id when you investigate a failure, and never include credentials in support reports.
{
"error": {
"code": "UNAUTHORIZED",
"message": "Provide a Bearer API key"
},
"request_id": "req_example"
}| Status | When |
|---|---|
400 | Invalid query, count or other input. |
401 | Missing, expired or revoked key. |
403 | Missing scope or unavailable account access. |
409 | An idempotency key was reused with different input, or the job already finished. |
413 | The request body is larger than 64 KiB. |
429 | Rate or queue limit. Respect the Retry-After header. |
| Limit | Value |
|---|---|
| Requests per key | 120 per minute |
| Requests per account | 240 per minute across all keys |
| Active jobs per project | 10 queued or running |
| Daily provider pages | Per project; see Usage |
Every limit is listed on the limits page.
Image metadata and reuse
| Field | Notes |
|---|---|
image_url | The full image on its original host. |
thumbnail_url | A smaller preview, when the source provides one. |
source_url | The page the image was found on. |
width, height | Reported by the source; dimensions_verified says whether they were checked. |
license_status | Always unknown unless a source supplies verifiable licence data. |
watermark_hint | A title and domain heuristic, not a guarantee. |
Search results grant no reuse rights
OpenAPI and tools
GET /v1/openapi.json is the OpenAPI 3.1 description. Import it into Postman, Insomnia or a code generator.
Results are removed after the retention period shown on each job (expires), 30 days after it finishes by default. Export what you need before then.
Was this page helpful?
Tell us what’s missing. We read every note.