Skip to main content

What is JSON-RPC?

JSON-RPC (Remote Procedure Call) is a protocol where you explicitly name the method you want to call. Unlike REST which uses different HTTP verbs and paths, JSON-RPC sends all requests to a single endpoint with the method name in the request body. Its killer feature? Batching - send multiple requests in one HTTP call.
{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "todos.create",
    "params": {"title": "Buy milk"}
}

Quick Start

import (
    "github.com/go-mizu/mizu"
    contract "github.com/go-mizu/mizu/contract/v2"
    "github.com/go-mizu/mizu/contract/v2/transport/jsonrpc"

    "yourapp/todo"
)

// Your interface is defined in the todo package as todo.API:
// type API interface {
//     Create(ctx context.Context, in *CreateInput) (*Todo, error)
//     List(ctx context.Context) (*ListOutput, error)
//     Get(ctx context.Context, in *GetInput) (*Todo, error)
// }

// Create your service implementation
impl := todo.NewService()

// Register your service
svc := contract.Register[todo.API](impl,
    contract.WithDefaultResource("todos"),
)

// Create mizu app
app := mizu.New()

// Mount JSON-RPC at /rpc
jsonrpc.Mount(app.Router, "/rpc", svc)

// Start server
app.Listen(":8080")
Now call methods:
curl -X POST http://localhost:8080/rpc \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "todos.create",
    "params": {"title": "Buy milk"}
  }'

Request Format

Every JSON-RPC request has the same structure:
{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "todos.create",
    "params": {"title": "Buy milk"}
}
FieldRequiredDescription
jsonrpcYesAlways "2.0"
idNo*Identifies your request (returned in response)
methodYesresource.method name to call
paramsNoObject with method parameters
*If you omit id, it becomes a “notification” (no response sent).

Response Format

Success Response

{
    "jsonrpc": "2.0",
    "id": 1,
    "result": {
        "id": "todo_1",
        "title": "Buy milk",
        "completed": false
    }
}

Error Response

{
    "jsonrpc": "2.0",
    "id": 1,
    "error": {
        "code": -32603,
        "message": "todo not found",
        "data": {"todoId": "123"}
    }
}

Method Naming

Method names follow the pattern resource.method:
Interface MethodJSON-RPC Method
Createtodos.create
Listtodos.list
Gettodos.get
Deletetodos.delete
Updatetodos.update
The resource name comes from WithDefaultResource or WithResource.

Batching

Batching lets you send multiple requests in one HTTP call. Instead of N HTTP round trips, you make just 1.

How to Batch

Send an array of requests:
curl -X POST http://localhost:8080/rpc \
  -H "Content-Type: application/json" \
  -d '[
    {"jsonrpc": "2.0", "id": 1, "method": "todos.create", "params": {"title": "First"}},
    {"jsonrpc": "2.0", "id": 2, "method": "todos.create", "params": {"title": "Second"}},
    {"jsonrpc": "2.0", "id": 3, "method": "todos.list"}
  ]'
Response:
[
    {"jsonrpc": "2.0", "id": 1, "result": {"id": "1", "title": "First", "completed": false}},
    {"jsonrpc": "2.0", "id": 2, "result": {"id": "2", "title": "Second", "completed": false}},
    {"jsonrpc": "2.0", "id": 3, "result": {"items": [...], "count": 2}}
]

When to Use Batching

  • Dashboard loads: Fetch user, settings, and recent items in one call
  • Bulk operations: Create 100 items without 100 HTTP requests
  • Related data: Get a todo and its comments together
  • Service-to-service: Microservices calling each other

Notifications (Fire and Forget)

Omit the id field to send a notification. The server won’t send a response:
curl -X POST http://localhost:8080/rpc \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc": "2.0", "method": "todos.create", "params": {"title": "Fire and forget"}}'
# HTTP 204 No Content
Use notifications for:
  • Logging or analytics events
  • Triggering background jobs
  • Sending metrics
You can batch notifications:
curl -X POST http://localhost:8080/rpc \
  -d '[
    {"jsonrpc": "2.0", "method": "analytics.log", "params": {"event": "page_view"}},
    {"jsonrpc": "2.0", "method": "analytics.log", "params": {"event": "click"}}
  ]'
# HTTP 204 No Content

Complete Example

This example shows a JSON-RPC service using the recommended package-based organization:
// todo/api.go
package todo

import "context"

// API defines the contract for todo operations
type API interface {
    Create(ctx context.Context, in *CreateInput) (*Todo, error)
    List(ctx context.Context) (*ListOutput, error)
    Get(ctx context.Context, in *GetInput) (*Todo, error)
}
// todo/service.go
package todo

import (
    "context"
    "fmt"
    "sync"

    contract "github.com/go-mizu/mizu/contract/v2"
)

// Service implements todo.API
type Service struct {
    mu     sync.RWMutex
    todos  map[string]*Todo
    nextID int
}

var _ API = (*Service)(nil)

func NewService() *Service {
    return &Service{todos: make(map[string]*Todo)}
}

func (s *Service) Create(ctx context.Context, in *CreateInput) (*Todo, error) {
    if in.Title == "" {
        return nil, contract.ErrInvalidArgument("title is required")
    }

    s.mu.Lock()
    defer s.mu.Unlock()

    s.nextID++
    todo := &Todo{
        ID:    fmt.Sprintf("%d", s.nextID),
        Title: in.Title,
    }
    s.todos[todo.ID] = todo
    return todo, nil
}

func (s *Service) List(ctx context.Context) (*ListOutput, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    items := make([]*Todo, 0, len(s.todos))
    for _, t := range s.todos {
        items = append(items, t)
    }
    return &ListOutput{Items: items, Count: len(items)}, nil
}

func (s *Service) Get(ctx context.Context, in *GetInput) (*Todo, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()

    todo, ok := s.todos[in.ID]
    if !ok {
        return nil, contract.ErrNotFound("todo not found")
    }
    return todo, nil
}
// main.go
package main

import (
    "fmt"

    "github.com/go-mizu/mizu"
    contract "github.com/go-mizu/mizu/contract/v2"
    "github.com/go-mizu/mizu/contract/v2/transport/jsonrpc"

    "yourapp/todo"
)

func main() {
    impl := todo.NewService()

    svc := contract.Register[todo.API](impl,
        contract.WithDefaultResource("todos"),
    )

    app := mizu.New()

    // Mount JSON-RPC at /rpc
    jsonrpc.Mount(app.Router, "/rpc", svc)

    fmt.Println("JSON-RPC server running at http://localhost:8080/rpc")
    fmt.Println("Methods: todos.create, todos.list, todos.get")
    app.Listen(":8080")
}

Test It

# Create a todo
curl -X POST http://localhost:8080/rpc \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"todos.create","params":{"title":"Buy milk"}}'

# List all todos
curl -X POST http://localhost:8080/rpc \
  -d '{"jsonrpc":"2.0","id":2,"method":"todos.list"}'

# Get a todo
curl -X POST http://localhost:8080/rpc \
  -d '{"jsonrpc":"2.0","id":3,"method":"todos.get","params":{"id":"1"}}'

# Batch request
curl -X POST http://localhost:8080/rpc \
  -d '[
    {"jsonrpc":"2.0","id":1,"method":"todos.create","params":{"title":"First"}},
    {"jsonrpc":"2.0","id":2,"method":"todos.create","params":{"title":"Second"}},
    {"jsonrpc":"2.0","id":3,"method":"todos.list"}
  ]'

Error Codes

JSON-RPC uses standard error codes:
CodeMessageWhat It Means
-32700Parse errorInvalid JSON
-32600Invalid RequestMissing jsonrpc or method
-32601Method not foundMethod doesn’t exist
-32602Invalid paramsWrong parameter types
-32603Internal errorYour method returned an error
Contract errors map to -32603 (Internal error) with the message from your error:
// In your method
return nil, contract.ErrNotFound("todo not found")

// JSON-RPC response
{
    "jsonrpc": "2.0",
    "id": 1,
    "error": {
        "code": -32603,
        "message": "todo not found",
        "data": {"code": "NOT_FOUND"}
    }
}

Multiple Services

Mount multiple services on the same endpoint:
todoSvc := contract.Register[todo.API](todoImpl, contract.WithDefaultResource("todos"))
userSvc := contract.Register[user.API](userImpl, contract.WithDefaultResource("users"))

app := mizu.New()

// Both mounted at /rpc
jsonrpc.Mount(app.Router, "/rpc", todoSvc)
jsonrpc.Mount(app.Router, "/rpc", userSvc)

// Now call:
// {"method": "todos.create", ...}
// {"method": "users.create", ...}

Combining with REST

Use JSON-RPC alongside REST:
import (
    "github.com/go-mizu/mizu/contract/v2/transport/rest"
    "github.com/go-mizu/mizu/contract/v2/transport/jsonrpc"
)

app := mizu.New()

// REST for browsers and simple clients
rest.Mount(app.Router, svc)

// JSON-RPC for batching and service-to-service
jsonrpc.Mount(app.Router, "/rpc", svc)

app.Listen(":8080")

JSON-RPC vs REST

Use JSON-RPC when…Use REST when…
You need batchingBrowser is the main client
Service-to-service callsYou want HTTP caching
Methods don’t map to CRUDTeam knows REST
You want explicit method namesBuilding a public API

Common Questions

Can I mix requests and notifications in a batch?

Yes! Requests (with id) get responses, notifications (without id) don’t:
[
    {"jsonrpc": "2.0", "id": 1, "method": "todos.get", "params": {"id": "1"}},
    {"jsonrpc": "2.0", "method": "analytics.log", "params": {"event": "viewed"}}
]
Response only includes the request:
[
    {"jsonrpc": "2.0", "id": 1, "result": {...}}
]

What ID values can I use?

Any JSON value works:
{"id": 1, ...}
{"id": "req-abc-123", ...}
{"id": null, ...}

How do I call methods without a resource?

If you didn’t use WithDefaultResource, use just the method name:
{"method": "Create", ...}
With a resource:
{"method": "todos.create", ...}

Can I generate an OpenRPC spec?

Yes:
spec, err := jsonrpc.OpenRPC(svc.Descriptor())

What’s Next?