AWS S3 Object Delivery
Write the complete cargo result — no size cap — as a JSON object in your S3 bucket. Every other direct-AWS target inlines at most ~240 KB and falls back to a pointer for oversize results; S3 delivery ships the full response every time. And it doubles as a trigger for free: attach S3 Event Notifications (→ Lambda / SQS / EventBridge) to the prefix.
your code ── POST /cargo/load ──▶ Convoy ──▶ …batch processing…
│
▼ cargo completes
AssumeRole(your role, ExternalId)
│
▼
s3:PutObject(Bucket: your-bucket,
Key: {key_prefix}{cargo_id}.json,
Body: <FULL result envelope>)
│
▼
S3 Event Notification on the prefix → Lambda / SQS / EventBridge
(or your pipeline just reads the object)Why S3 delivery:
- No size cap — the object always carries the complete
response;response_truncatedis alwaysfalse. This is the target for large results. - You own the data at rest — your bucket, your retention (lifecycle rules), your encryption, your access control, your data-residency story.
- Exactly-once effective — the object key is deterministic
(
{key_prefix}{cargo_id}.json), so a delivery retry simply overwrites the same object with identical bytes. (Buckets with versioning enabled will record a duplicate version — harmless.) - Write-only, prefix-scoped — Convoy only ever gets
s3:PutObjectscoped to your agreed prefix. It can never read or list your bucket, and it cannot write outside the prefix even if your role policy is bucket-wide (each delivery session carries a prefix-scoped session policy on top of your role policy).
Object shape
| Property | Value |
|---|---|
| Key | {key_prefix}{cargo_id}.json (default prefix convoy/) |
| Content-Type | application/json |
| Body | the standard result envelope — full response, never truncated |
Metadata convoy-cargo-id | the cargo ID |
Metadata convoy-success | true | false |
The object metadata lets event-notification consumers and lifecycle tooling filter without reading the body.
S3 bucket ARNs are region-less, so this is the one target with no region check against your connection: Convoy resolves the bucket’s actual region at delivery time (and caches it). Your bucket can live in any region — the full result lands wherever the bucket lives, which is a feature for data-residency requirements.
Setup
Create an AWS connection
Same connection as the other direct-AWS targets — see AWS connections. One connection can carry every capability.
Grant s3:PutObject on your role — prefix-scoped
Scope it to the bucket AND prefix — never the whole bucket unless you
mean it, and never Resource: "*":
{
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::acme-cargo-results/convoy/*"
}Or use the CloudFormation role template
with the S3BucketPrefixArns parameter (you supply the already-prefix-
scoped resource string — the template never widens it).
If the bucket default-encrypts with a customer-managed KMS key
(SSE-KMS), also grant the role kms:GenerateDataKey on that key —
standard S3-writer setup.
Verify the capability (optional)
In the dashboard, open your AWS connection and run the S3 capability check with your bucket ARN and key prefix.
PutObject has no dry-run mode, so the s3 probe writes one real
marked object — {key_prefix}convoy-verify.json with body
{"convoy_verification": true}. The key is deterministic, so repeated
probes overwrite the same tiny object (no accumulation); you can
lifecycle-expire or event-filter the convoy-verify.json key.
Side-effectful probes are rate-capped at 1/minute per connection, and probes are opt-in per capability — fail-at-delivery is a valid choice.
Submit cargo with the aws_s3 callback
curl -X POST https://api.cnvy.ai/cargo/load \
-H "X-API-Key: $CONVOY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"params": {
"model": "claude-haiku-4-5",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Summarize this document…"}]
},
"callback": {
"type": "aws_s3",
"connection_id": "awsconn_…",
"bucket_arn": "arn:aws:s3:::acme-cargo-results",
"key_prefix": "convoy/results/"
},
"metadata": {"job_id": "batch-2026-08-29"}
}'bucket_arn—arn:aws:s3:::bucketonly, no key path. When your connection carries a target allow-list, the ARN must be listed on it.key_prefix— optional, defaults toconvoy/. Must end with/when non-empty; no..segments, no leading/(422 invalid_key_prefixotherwise). The result lands at{key_prefix}{cargo_id}.json.
Triggering on delivery — S3 Event Notifications
Attach a notification to the prefix so an object write fires your consumer:
# SAM/CloudFormation — bucket notification → Lambda
ResultsBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: acme-cargo-results
NotificationConfiguration:
LambdaConfigurations:
- Event: s3:ObjectCreated:Put
Filter:
S3Key:
Rules:
- Name: prefix
Value: convoy/results/
- Name: suffix
Value: .json
Function: !GetAtt OnCargoResult.ArnYour consumer reads the object (or just its convoy-* metadata) and
processes the full result:
import boto3, json
s3 = boto3.client("s3")
def handler(event, context):
for record in event["Records"]:
bucket = record["s3"]["bucket"]["name"]
key = record["s3"]["object"]["key"]
# Skip the verification probe object.
if key.endswith("convoy-verify.json"):
continue
head = s3.head_object(Bucket=bucket, Key=key)
if head["Metadata"].get("convoy-success") != "true":
handle_failure(bucket, key)
continue
envelope = json.loads(
s3.get_object(Bucket=bucket, Key=key)["Body"].read()
)
process(envelope["cargo_id"], envelope["response"], envelope["metadata"])Lifecycle hygiene: add a lifecycle rule on the prefix so old results expire on your schedule — result retention in your bucket is entirely yours (the hosted results mailbox keeps its own independent TTL).
The result envelope
The object body is the same envelope every direct-AWS target uses —
except response is always fully inlined here:
| Field | Notes |
|---|---|
cargo_id | matches the object key’s {cargo_id} |
success | true | false (also in object metadata) |
response | the FULL model response — never truncated for S3 delivery |
response_truncated | always false for S3 delivery |
result_url | hosted-mailbox URL (consistent with other targets; the object already has everything) |
metadata | your submit-time metadata, echoed verbatim |
error_message | present only when success is false |
Failure cargo
There is no separate failure API — failed cargo writes the same
deterministic key with success: false + error_message in the body and
convoy-success: false in the object metadata, so consumers can branch
on the metadata without reading the body.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
422 invalid_key_prefix at submit | Prefix has a leading /, a .. segment, a disallowed character, or a missing trailing / — fix the key_prefix. |
422 target_not_allowed at submit | The bucket ARN isn’t on the connection’s target allow-list. |
Delivery fails target_not_found | The bucket was deleted since submit. |
Delivery fails assume_role_denied | s3:PutObject grant removed, a bucket policy Deny, or a broken trust policy — the connection is flagged failed and your org is emailed; fix the role and re-verify. |
Delivery fails target_misconfigured (KMS.*) | The bucket default-encrypts with a customer-managed key the role can’t use — grant kms:GenerateDataKey on that key. |
Repeated SlowDown retries | S3 prefix throttling — Convoy’s backoff handles it; consider spreading keys across prefixes at very high volumes. |
| Duplicate object versions | Versioned bucket + a delivery retry — the versions carry identical bytes; harmless. |