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

# Update a blog post (required)

> **Partial update — apply only the fields present in the body.**

This is the rule most integrations get wrong, and the one that costs real
content. Castro's *Update Content* action sends nothing but `content`; a
full *Update Content & Metadata* sends every field. If you replace the
record with whatever arrived, the first case wipes the title, categories,
author and SEO the user set.

```js
// WRONG — replaces the row with whatever arrived.
await db.posts.replace(id, body);

// RIGHT — merge only the keys that are present.
for (const [k, v] of Object.entries(body)) {
  if (v !== undefined) post[k] = v;
}
```




## OpenAPI

````yaml /openapi/contract.yaml put /posts/{id}
openapi: 3.1.0
info:
  title: Custom Integration contract (v1)
  version: '1.0'
  summary: The REST endpoints you implement so Jorge Castro can publish to your site.
  description: >
    <div style="padding:12px 16px;border-left:3px solid
    #9827FC;background:rgba(152,39,252,0.06);border-radius:0 8px 8px
    0;margin-bottom:16px">

    <strong>Read this backwards.</strong> Jorge Castro is the <em>client</em>
    here —

    <strong>you implement these endpoints on your own server</strong>. Nothing
    below runs on

    our infrastructure.

    </div>


    When a user clicks Publish, Update or Delete in Jorge Castro, our servers
    send

    signed HTTPS requests to the base URL you registered (for example

    `https://your-site.com/api/castro`) using the paths below. Your
    implementation

    stores the content wherever your site reads it from, and returns the ids

    described here.


    ## Only three endpoints are required


    `POST /handshake`, `POST /posts` and `PUT /posts/{id}`. Everything else is

    optional: declare what you built in the handshake's `capabilities` array,
    and

    Castro only ever calls what you declared. See

    [Capabilities](/capabilities).


    ## Two rules that govern every endpoint


    **1. `PUT` is a partial update.** Only the fields present in the body
    change;

    omitted fields keep their current values. Castro's *Update Content* action

    sends nothing but `content` — treating that as a full replace erases the

    user's title, categories and SEO. Create and update therefore have

    **different schemas** below (`PostCreate` vs `PostUpdate`), and the
    difference

    is the whole contract.


    **2. Every request is signed.** Three headers travel with each one; verify
    the

    HMAC over the **raw** request bytes, never over a re-serialized copy. See

    [Authentication](/authentication).


    ## Before you connect


    Run the [conformance script](/testing) against your endpoints. It asserts
    both

    rules above, plus that a bad signature is actually rejected.
  contact:
    name: Jorge Castro
    url: https://jorgecastro.ai
servers:
  - url: https://your-site.com/{basePath}
    description: Your server — the base URL you enter in Jorge Castro
    variables:
      basePath:
        default: api/castro
        description: Any prefix you like. Castro appends the paths below to it.
security:
  - apiKey: []
    castroTimestamp: []
    castroSignature: []
tags:
  - name: Handshake
    description: Required — connection verification and capability discovery.
  - name: Blog posts
    description: Create and update are required; delete is optional (`posts.delete`).
  - name: Blog categories
    description: Optional — capability `blog_categories`.
  - name: Authors
    description: Optional — capability `authors`.
  - name: Products
    description: Optional — capability `products`. Copy only (no price/SKU/stock).
  - name: Product categories
    description: Optional — capability `product_categories`.
  - name: SEO
    description: Optional — capability `seo`.
  - name: Pages
    description: Optional — capability `pages.list`.
paths:
  /posts/{id}:
    put:
      tags:
        - Blog posts
      summary: Update a blog post (required)
      description: >
        **Partial update — apply only the fields present in the body.**


        This is the rule most integrations get wrong, and the one that costs
        real

        content. Castro's *Update Content* action sends nothing but `content`; a

        full *Update Content & Metadata* sends every field. If you replace the

        record with whatever arrived, the first case wipes the title,
        categories,

        author and SEO the user set.


        ```js

        // WRONG — replaces the row with whatever arrived.

        await db.posts.replace(id, body);


        // RIGHT — merge only the keys that are present.

        for (const [k, v] of Object.entries(body)) {
          if (v !== undefined) post[k] = v;
        }

        ```
      operationId: updatePost
      parameters:
        - $ref: '#/components/parameters/EntityId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostUpdate'
            examples:
              contentOnly:
                summary: Update Content — the case that breaks integrations
                description: >
                  Only the body changes. The title, categories, author and SEO
                  stored on this post must survive untouched.
                value:
                  content: <p>The revised article body.</p>
                  source_id: cnt_9f2ka83b
              statusChange:
                summary: Change status
                value:
                  status: draft
              full:
                summary: Update Content & Metadata
                value:
                  title: 10 Best Running Shoes in 2026
                  content: <p>Choosing the right running shoe...</p>
                  excerpt: Our tested picks for every kind of runner.
                  status: publish
                  categories:
                    - Running
                    - Gear
                  tags:
                    - shoes
                    - running
                  author: Jane Levy
                  seo:
                    title: 10 Best Running Shoes in 2026
                    description: Our tested picks for every kind of runner.
                    keywords:
                      - running shoes
                  source_id: cnt_9f2ka83b
      responses:
        '200':
          description: Post updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EntityResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
components:
  parameters:
    EntityId:
      name: id
      in: path
      required: true
      schema:
        type: string
      description: |
        The id **your** server returned when the entity was created. Castro
        stores it verbatim and never rewrites it.
      example: '8842'
  schemas:
    PostUpdate:
      description: |
        The body of `PUT /posts/{id}` — a **partial** update. Any subset of the
        fields may arrive, and **only those fields change**. Everything you are
        not sent must keep its current value.

        Three different Castro actions come through this one endpoint:

        | Castro action | What you receive |
        | --- | --- |
        | Update Content & Metadata | Every field (same shape as create) |
        | Update Content | `{ "content": "…", "source_id": "…" }` |
        | Change status | `{ "status": "draft" }` |

        Treat the second as a full replace and you erase the user's title,
        categories, author and SEO. Nested objects follow the same rule — merge
        `seo`, don't replace it.
      allOf:
        - $ref: '#/components/schemas/PostFields'
    EntityResponse:
      type: object
      required:
        - id
      properties:
        id:
          type: string
          maxLength: 191
          description: Your id for the entity — any string; Castro stores it verbatim.
          example: '8842'
        url:
          type: string
          description: Public URL of the entity (optional but recommended).
          example: https://your-site.com/blog/10-best-running-shoes
    PostFields:
      type: object
      properties:
        title:
          type: string
          example: 10 Best Running Shoes in 2026
        content:
          type: string
          description: >-
            Post body as HTML. The H1 is already stripped — render `title` as
            your page heading.
          example: <p>Choosing the right running shoe...</p>
        excerpt:
          type: string
          description: Short summary / meta description text.
        status:
          type: string
          enum:
            - publish
            - draft
        date:
          type: string
          description: Publish datetime, `YYYY-MM-DD HH:MM:SS`.
          example: '2026-07-03 14:22:01'
        categories:
          type: array
          items:
            type: string
          description: Category NAMES. Create any that don't exist yet.
          example:
            - Running
            - Gear
        tags:
          type: array
          items:
            type: string
          example:
            - shoes
            - running
        author:
          type: string
          description: Author display name.
          example: Jane Levy
        featured_image:
          type: string
          description: URL of the featured image, hosted by Castro.
        reading_time:
          type: string
          example: 5 minutes
        seo:
          $ref: '#/components/schemas/Seo'
        source_id:
          type: string
          description: Castro's internal content id — store it for idempotency.
          example: cnt_9f2ka83b
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: string
          description: |
            Human-readable message, surfaced to the Castro user as-is. Make it
            actionable — "Post not found" is useful, "Error" is not.
          example: Post not found
    Seo:
      type: object
      properties:
        title:
          type: string
        description:
          type: string
        keywords:
          type: array
          items:
            type: string
  responses:
    Unauthorized:
      description: |
        Authentication failed — bad API key, bad signature, or a stale
        timestamp. Castro surfaces this as a connection problem and does not
        retry.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            badSignature:
              summary: Signature mismatch
              value:
                error: Invalid signature
            stale:
              summary: Timestamp too old
              value:
                error: Stale request
    NotFound:
      description: No entity exists with that id.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: Post not found
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        The connection key the user generated in Jorge Castro
        (Settings → Integration → Custom Website). Reject any request whose key
        doesn't match yours.
    castroTimestamp:
      type: apiKey
      in: header
      name: X-Castro-Timestamp
      description: |
        Unix time in **milliseconds** when the request was signed — Castro uses
        `Date.now()`. Reject anything more than a few minutes old.

        A seconds-based comparison makes every request look ~55,000 years in the
        future, and the freshness check then silently passes everything.
    castroSignature:
      type: apiKey
      in: header
      name: X-Castro-Signature
      description: |
        `HMAC-SHA256("{timestamp}.{rawBody}", api_key)`, lowercase hex.

        `rawBody` is the **exact bytes** of the request body — empty for `GET`
        and `DELETE`, which therefore sign over `"{timestamp}."`. Hash the raw
        bytes, never a re-serialized copy of the parsed JSON: whitespace and key
        order differences will break the comparison for some payloads and not
        others, which reads as an intermittent bug.

        Compare in constant time (`crypto.timingSafeEqual`, `hash_equals`,
        `hmac.compare_digest`).

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.