# Create or update a contact

Source: https://jointextkit.com/docs/api/contacts/upsert
Index: https://jointextkit.com/llms.txt

`POST /v1/contacts`

One contact per phone number. Creates the contact (201) or updates the one that already has this number (200). Values you leave out are kept and tags are added, never removed; use PATCH to replace them.

Send your API key as a bearer token: `Authorization: Bearer ssms_sk_...`. Test keys run this endpoint against the [sandbox](/docs/sandbox); see [Authentication](/docs/authentication) for key modes and scopes.

## Request body

JSON (`Content-Type: application/json`).

A `ContactInput` object.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `phone_number` | string | Yes | E.164, US or Canada |
| `name` | string | No | Display name. Defaults to first + last name. At most 120 characters. |
| `tags` | array of string | No | At most 20 items. |
| `fields` | object | No | Up to 20 string values (account id, plan, location...), usable in segments and as {{field:key}} merge fields |
| `notes` | string | No | At most 2000 characters. |
| `first_name` | string | No | At most 80 characters. |
| `last_name` | string | No | At most 80 characters. |
| `email` | string | No | At most 254 characters. |
| `company` | string | No | At most 120 characters. |
| `state` | string | No | At most 40 characters. Example: `TN`. |
| `source` | string | No | Where the contact came from. Defaults to "api". At most 60 characters. |
| `opt_in` | object | No | Import only (POST /v1/contacts/import). Stored as consent evidence; never lifts an opt-out. |
| `opt_in.status` | boolean | No | true = opted in; false = unsubscribed (recorded as an opt-out) |
| `opt_in.at` | string | No | When they opted in: ISO timestamp, date, or epoch ms |
| `opt_in.source` | string | No | web_form, keyword, paper, checkout or verbal are stored as proof; anything else is kept as a note |
| `opt_in.campaign_id` | string | No | The registration the opt-in is for. A marketing or mixed registration needs at, a recognised source, and page_url for a web form, or the row is skipped Example: `reg_a1B2c3D4e5F6`. |
| `opt_in.page_url` | string (uri) | No | The page a web-form opt-in was collected on |

## Responses

| Status | Meaning | Body |
| --- | --- | --- |
| 200 | The existing contact, updated | `Contact` |
| 201 | The new contact | `Contact` |
| 400 | Invalid request | `Error` |
| 401 | Missing, malformed, or revoked API key | `Error` |
| 402 | Billable live calls only: the prepaid credit balance cannot cover the call (insufficient_credits); nothing was done or charged | `Error` |
| 429 | Rate limit or quota exceeded | `Error` |

### 200: `Contact` fields

| Field | Type | Description |
| --- | --- | --- |
| `id` | string | Example: `ct_a1B2c3D4e5F6`. |
| `object` | `contact` |  |
| `phone_number` | string | Example: `+14155550132`. |
| `name` | string | Nullable. |
| `tags` | array of string | Free-form labels; they double as broadcast audiences |
| `fields` | object |  |
| `notes` | string | Nullable. |
| `first_name` | string | Nullable. |
| `last_name` | string | Nullable. |
| `email` | string | Nullable. |
| `company` | string | Nullable. |
| `state` | string | Nullable. |
| `source` | string | Nullable. |
| `import_id` | string | The last import that touched this contact Nullable. |
| `opt_in` | object | Nullable. |
| `opt_in.source` | string | Nullable. |
| `opt_in.collected_at` | string (date-time) | Nullable. |
| `created_at` | string (date-time) |  |
| `updated_at` | string (date-time) |  |
| `consent_status` | `opted_out`, `opted_in`, `no_record` | Only on retrieve |
| `topics` | array of TopicSubscription | Only on retrieve |
| `segments` | array of object | Only on retrieve |
| `segments[].id` | string |  |
| `segments[].name` | string |  |

## Errors

| Status | When |
| --- | --- |
| 400 | Invalid request |
| 401 | Missing, malformed, or revoked API key |
| 402 | Billable live calls only: the prepaid credit balance cannot cover the call (insufficient_credits); nothing was done or charged |
| 429 | Rate limit or quota exceeded |

Every error has the same JSON shape, and `request_id` matches the `X-Request-Id` response header. [Errors](/docs/errors) lists every code and what to do about it.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "What went wrong, in plain words.",
    "param": "the_field",
    "request_id": "req_a1B2c3D4e5F6g7H8"
  }
}
```

## Examples

### Node.js

```javascript
import { TextKit } from 'jointextkit'; // npm install jointextkit

const sms = new TextKit(process.env.TEXTKIT_API_KEY);

const contact = await sms.contacts.upsert({
  phoneNumber: '+14155550132',
  name: 'Jane Doe',
  tags: ['customers'],
});

console.log(contact.id);
```

### PHP

```php
<?php

$ch = curl_init('https://api.jointextkit.com/v1/contacts');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('TEXTKIT_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'phone_number' => '+14155550132',
        'state' => 'TN',
    ]),
]);

$data = json_decode(curl_exec($ch), true);
```

### Python

```python
import os
from jointextkit import TextKit  # pip install jointextkit

client = TextKit(os.environ["TEXTKIT_API_KEY"])

contact = client.contacts.upsert(
    "+14155550132",
    name="Jane Doe",
    tags=["customers"],
)

print(contact["id"])
```

### Ruby

```ruby
require "net/http"
require "json"

uri = URI("https://api.jointextkit.com/v1/contacts")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{ENV["TEXTKIT_API_KEY"]}"
req["Content-Type"] = "application/json"
req.body = {
  "phone_number": "+14155550132",
  "state": "TN"
}.to_json

res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)
```

### Go

```go
package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

func main() {
	body := strings.NewReader(`{
	  "phone_number": "+14155550132",
	  "state": "TN"
	}`)
	req, _ := http.NewRequest("POST", "https://api.jointextkit.com/v1/contacts", body)
	req.Header.Set("Authorization", "Bearer "+os.Getenv("TEXTKIT_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	data, _ := io.ReadAll(res.Body)
	fmt.Println(res.Status, string(data))
}
```

### Rust

```rust
// cargo add reqwest --features blocking,json
// cargo add serde_json
use serde_json::json;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let key = std::env::var("TEXTKIT_API_KEY")?;
    let res = reqwest::blocking::Client::new()
        .post("https://api.jointextkit.com/v1/contacts")
        .bearer_auth(key)
        .json(&json!({
          "phone_number": "+14155550132",
          "state": "TN"
        }))
        .send()?
        .error_for_status()?;
    let data: serde_json::Value = res.json()?;
    println!("{data}");
    Ok(())
}
```

### Java

```java
import java.net.URI;
import java.net.http.*;

public class Main {
    public static void main(String[] args) throws Exception {
        var request = HttpRequest.newBuilder(URI.create("https://api.jointextkit.com/v1/contacts"))
            .header("Authorization", "Bearer " + System.getenv("TEXTKIT_API_KEY"))
            .header("Content-Type", "application/json")
            .method("POST", HttpRequest.BodyPublishers.ofString("""
                {
                  "phone_number": "+14155550132",
                  "state": "TN"
                }
                """))
            .build();

        var response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode() + " " + response.body());
    }
}
```

### .NET

```csharp
using System.Net.Http.Headers;
using System.Text;

var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("TEXTKIT_API_KEY"));

var body = new StringContent("""
    {
      "phone_number": "+14155550132",
      "state": "TN"
    }
    """, Encoding.UTF8, "application/json");
var res = await http.PostAsync("https://api.jointextkit.com/v1/contacts", body);
res.EnsureSuccessStatusCode();

var data = await res.Content.ReadAsStringAsync();
```

### cURL

```bash
curl -X POST "https://api.jointextkit.com/v1/contacts" \
  -H "Authorization: Bearer $TEXTKIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "phone_number": "+14155550132",
  "state": "TN"
}'
```

## Related

- Guide: [Contacts](/docs/contacts)
- [All contacts endpoints](/docs/api#contacts)
- [API reference](/docs/api)
