> ## Documentation Index
> Fetch the complete documentation index at: https://developers.safarapi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Issue a presigned PUT URL to upload a review photo to R2

> Issues a short-lived presigned URL that the bank can use to upload a single
review photo directly to Safariat's R2 storage. The bank then includes the
returned `public_url` in `POST /reviews.photo_urls` to attach the photo to
a new review.

Flow:
1. Bank → `POST /files/presigned-upload` → receives `put_url` + `public_url`.
2. Bank → `PUT <put_url>` with the image binary directly to R2 (does not transit
   through SafarAPI servers — zero bandwidth cost on our side).
3. Bank → `POST /reviews` with `photo_urls: ["<public_url>"]`.

Constraints:
- `content_type` must be one of `image/jpeg`, `image/png`, `image/webp`.
- `expected_size` between 1 byte and 10 MB (10 485 760 bytes).
- The `put_url` expires after 5 minutes — re-issue another ticket if upload
  is slower (e.g. mobile network on the bank side).
- The returned `public_url` is namespaced under
  `partner-reviews/<your-partner-id>/...`; URLs that don't match this prefix
  cannot be linked to a review by your account.

Requires scope `reviews:write`.




## OpenAPI

````yaml /api-reference/openapi.yaml post /files/presigned-upload
openapi: 3.1.0
info:
  title: SafarAPI
  version: 1.0.0
  summary: B2B API for banking partners — Safariat catalog and booking
  description: >
    SafarAPI lets banking partners integrate the Safariat travel catalog into
    their

    authenticated customer areas and book on behalf of their own end customers
    who are

    already authenticated and KYC-verified on the bank side.


    ## Channel model

    - The traveler never authenticates with Safariat.

    - Payment is collected on the banking platform (out-of-band from Safariat).

    - The bank submits the HMAC-signed payment confirmation at booking time.

    - Monthly post-payment settlement (see `GET /settlements`).


    ## Authentication

    Bearer API key in the `Authorization` header. Format
    `sk_live_<prefix>_<secret>` (production)

    or `sk_test_<prefix>_<secret>` (sandbox). The secret is shown only once at
    key generation —

    Safariat stores only an argon2id hash.


    ## Write request signing

    Production write requests (`POST`, `PUT`, `DELETE`) additionally require:

    - `X-Timestamp`: Unix timestamp in seconds, validity window ±5 minutes.

    - `X-Signature`: `hex(HMAC_SHA256(secret,
    "{timestamp}\n{method}\n{path}\n{body}"))`.

    - `Idempotency-Key`: opaque string unique per operation (UUID v4
    recommended),
      retained for 24 h. Replayed responses carry an `Idempotent-Replayed: true` header.

    Request signing applies only to production `sk_live_*` keys. Sandbox
    `sk_test_*` keys are

    exempt from request signing: `X-Signature` and `X-Timestamp` are not
    required for writes

    in the sandbox (so the developer-portal "Try it" playground works end to
    end).

    `Idempotency-Key` is still required on writes in both environments.


    ## Error format

    All errors follow the `Error` schema with a stable i18n code, an English
    message, and the

    `request_id` for correlation with Safariat logs.


    ## Versioning

    URL-based versioning (`/v1`). No breaking changes within a version. Removals
    are announced

    6 months in advance via the `Sunset` header (RFC 8594).


    ## Rate limiting

    Default limit of 600 req/min per key, contractually adjustable. The
    `X-RateLimit-Limit`,

    `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers are returned on
    every response.

    Exceeding the limit → `429 Too Many Requests` plus the `Retry-After` header.


    ## Sandbox

    An `sk_test_*` key targets the same infrastructure as production but
    isolates bookings in

    `test_mode=true`: no real email is sent, no settlement is issued, and
    webhooks are marked

    `test: true`.
  contact:
    name: SafarAPI Team
    email: api@safariat.ma
    url: https://developers.safariat.ma
  license:
    name: Proprietary — use subject to a signed partner agreement
  termsOfService: https://safariat.ma/legal/partner-terms
servers:
  - url: https://api.safarapi.com/api/partner/v1
    description: >-
      Production and sandbox share this host. The environment is selected by the
      API key prefix: sk_live_ targets production, sk_test_ targets the sandbox.
security:
  - apiKey: []
tags:
  - name: Meta
    description: Metadata for the current key, service health.
  - name: Catalog
    description: Browsing the Safariat adventure catalog.
  - name: Quotes
    description: Price-locked quotes (30 min TTL) ahead of booking.
  - name: Bookings
    description: Creating, retrieving, and cancelling bookings.
  - name: Settlements
    description: Monthly Safariat → partner payout invoices.
  - name: Webhooks
    description: Managing endpoints that receive Safariat events.
  - name: Team
    description: Members of the partner console workspace and their roles.
  - name: Reviews
    description: Read-only access to traveler reviews on the Safariat catalog.
  - name: Files
    description: Presigned uploads for partner-supplied files (currently review photos).
paths:
  /files/presigned-upload:
    post:
      tags:
        - Files
      summary: Issue a presigned PUT URL to upload a review photo to R2
      description: >
        Issues a short-lived presigned URL that the bank can use to upload a
        single

        review photo directly to Safariat's R2 storage. The bank then includes
        the

        returned `public_url` in `POST /reviews.photo_urls` to attach the photo
        to

        a new review.


        Flow:

        1. Bank → `POST /files/presigned-upload` → receives `put_url` +
        `public_url`.

        2. Bank → `PUT <put_url>` with the image binary directly to R2 (does not
        transit
           through SafarAPI servers — zero bandwidth cost on our side).
        3. Bank → `POST /reviews` with `photo_urls: ["<public_url>"]`.


        Constraints:

        - `content_type` must be one of `image/jpeg`, `image/png`, `image/webp`.

        - `expected_size` between 1 byte and 10 MB (10 485 760 bytes).

        - The `put_url` expires after 5 minutes — re-issue another ticket if
        upload
          is slower (e.g. mobile network on the bank side).
        - The returned `public_url` is namespaced under
          `partner-reviews/<your-partner-id>/...`; URLs that don't match this prefix
          cannot be linked to a review by your account.

        Requires scope `reviews:write`.
      operationId: issuePartnerReviewPhotoUpload
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
        - $ref: '#/components/parameters/XTimestamp'
        - $ref: '#/components/parameters/XSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IssuePartnerUploadRequest'
      responses:
        '201':
          description: Presigned upload ticket issued
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PartnerUploadTicket'
              example:
                put_url: >-
                  https://r2.cloudflarestorage.com/safariat-public/partner-reviews/1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f/9f2a8b6c-7d3e-4a5f-8b6c-1d2e3f4a5b6c.jpg?X-Amz-Algorithm=...
                public_url: >-
                  https://storage.safariat.ma/partner-reviews/1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f/9f2a8b6c-7d3e-4a5f-8b6c-1d2e3f4a5b6c.jpg
                content_type: image/jpeg
                max_bytes: 10485760
                expires_at: '2026-05-26T12:05:00Z'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '429':
          $ref: '#/components/responses/TooManyRequests'
components:
  parameters:
    IdempotencyKey:
      in: header
      name: Idempotency-Key
      required: true
      schema:
        type: string
        maxLength: 255
      description: >
        Opaque, client-generated string unique per logical operation (a UUID v4
        is

        recommended but any non-blank value up to 255 chars is accepted). Same
        key +

        same body = the cached response is returned with the
        `Idempotent-Replayed: true`

        header. Same key + different body = `409 Conflict`. Retained for 24 h.
    XTimestamp:
      in: header
      name: X-Timestamp
      required: true
      schema:
        type: integer
        format: int64
      description: >
        Unix timestamp in seconds at the time the request is issued. Validity
        window

        ±5 minutes — beyond that the request is rejected. Required for
        production

        `sk_live_*` keys only; not required for sandbox `sk_test_*` keys.
    XSignature:
      in: header
      name: X-Signature
      required: true
      schema:
        type: string
        pattern: ^[a-f0-9]{64}$
      description: >
        `hex(HMAC_SHA256(secret, "{X-Timestamp}\n{METHOD}\n{path}\n{body}"))`.

        The `path` includes the query string. The `body` is the exact JSON
        representation

        sent — any reformatting invalidates the signature. Required for
        production

        `sk_live_*` keys only; not required for sandbox `sk_test_*` keys.
  schemas:
    IssuePartnerUploadRequest:
      type: object
      required:
        - content_type
        - file_name
        - expected_size
      properties:
        content_type:
          type: string
          enum:
            - image/jpeg
            - image/png
            - image/webp
          example: image/jpeg
        file_name:
          type: string
          maxLength: 255
          example: dunes-sunset.jpg
          description: >-
            Original filename from the user's device. Used only for logging and
            extension hinting — Safariat generates a UUID-based key on R2.
        expected_size:
          type: integer
          format: int64
          minimum: 1
          maximum: 10485760
          example: 256000
          description: File size in bytes. Capped at 10 MB.
    PartnerUploadTicket:
      type: object
      required:
        - put_url
        - public_url
        - content_type
        - max_bytes
        - expires_at
      properties:
        put_url:
          type: string
          format: uri
          description: >-
            Presigned PUT URL on R2. The bank MUST PUT the binary directly to
            this URL with the same `Content-Type` header as the one used to
            issue the ticket.
        public_url:
          type: string
          format: uri
          description: >-
            Canonical public URL of the file once uploaded. Include this in
            `POST /reviews.photo_urls` to attach the photo to a review.
        content_type:
          type: string
          enum:
            - image/jpeg
            - image/png
            - image/webp
        max_bytes:
          type: integer
          format: int64
          description: Echoed cap (10 MB).
        expires_at:
          type: string
          format: date-time
          description: After this instant the `put_url` is no longer accepted by R2.
    Error:
      type: object
      required:
        - code
        - message
        - request_id
      properties:
        code:
          type: string
          description: |
            Stable, documented i18n code. Form `domain.subdomain.detail`
            (e.g. `payment.amount.mismatch`, `auth.api_key.invalid`).
          example: validation.required.field
        message:
          type: string
          description: >-
            English message (for bank logs). API errors are never localized on
            the Safariat side.
          example: Field 'rate_pack_id' is required.
        request_id:
          type: string
          description: Unique request ID on the Safariat side.
        details:
          type: object
          description: Optional structured details (offending field, expected value, etc.).
          additionalProperties: true
  responses:
    BadRequest:
      description: Malformed request (syntactic validation).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missingField:
              value:
                code: validation.required.field
                message: Field 'rate_pack_id' is required.
                request_id: 01HXY2ZRPM3R8K9PSBTRQYNFHC
    Unauthorized:
      description: API key missing, invalid, expired, or incorrect HMAC signature.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            missing:
              value:
                code: auth.api_key.missing
                message: Authorization header is required.
                request_id: 01HXY2ZRPM3R8K9PSBTRQYNFHC
            invalid:
              value:
                code: auth.api_key.invalid
                message: The provided API key is invalid or has been revoked.
                request_id: 01HXY2ZRPM3R8K9PSBTRQYNFHC
            signatureInvalid:
              value:
                code: auth.signature.invalid
                message: HMAC signature does not match.
                request_id: 01HXY2ZRPM3R8K9PSBTRQYNFHC
            timestampSkew:
              value:
                code: auth.timestamp.skew
                message: Timestamp is more than 5 minutes off server time.
                request_id: 01HXY2ZRPM3R8K9PSBTRQYNFHC
    Forbidden:
      description: Authenticated but not authorized (missing scope, non-allowlisted IP).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UnprocessableEntity:
      description: Syntactically correct request but a business rule was violated.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    TooManyRequests:
      description: Rate limit exceeded for the current key.
      headers:
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
        X-RateLimit-Limit:
          $ref: '#/components/headers/XRateLimitLimit'
        X-RateLimit-Remaining:
          $ref: '#/components/headers/XRateLimitRemaining'
        X-RateLimit-Reset:
          $ref: '#/components/headers/XRateLimitReset'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            code: rate_limit.exceeded
            message: Rate limit exceeded for this API key.
            request_id: 01HXY2ZRPM3R8K9PSBTRQYNFHC
  headers:
    RetryAfter:
      schema:
        type: integer
      description: Seconds to wait before the next attempt (RFC 7231).
    XRateLimitLimit:
      schema:
        type: integer
      description: Request limit per minute for the current key.
    XRateLimitRemaining:
      schema:
        type: integer
      description: Requests remaining in the current window.
    XRateLimitReset:
      schema:
        type: integer
        format: int64
      description: Unix timestamp (s) at which the window resets.
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: sk_live_<prefix>_<secret> or sk_test_<prefix>_<secret>
      x-default: sk_test_DEMO0000_replace_with_your_sandbox_key
      description: >
        API key authentication. Issued by the Safariat admin or via the partner
        portal.

        The secret is shown only once at generation.


        Production `sk_live_*` keys must additionally sign every write request

        (`POST`/`PUT`/`DELETE`) with the `X-Timestamp` and `X-Signature`
        headers.

        Sandbox `sk_test_*` keys are exempt from request signing: `X-Signature`
        and

        `X-Timestamp` are not required for writes in the sandbox (so the
        developer-portal

        "Try it" playground works end to end). The `Idempotency-Key` header
        remains

        required on writes in both environments.

````