Sender Profiles: Multi-Tenant Resource Governance

Sender Profiles provide an architectural abstraction that enables true multi-tenancy within a single Sent organization. Rather than creating separate accounts for each business unit, brand, or customer, Sender Profiles allow you to isolate messaging identities while selectively sharing resources across organizational boundaries.

The Sender Profile Abstraction

Traditional approaches to multi-tenant messaging force a binary choice: completely separate accounts with full isolation (and operational overhead), or a shared account with no isolation (and security risks). Sender Profiles introduce a governed resource model that allows selective isolation and sharing on a per-resource basis.

The Inheritance Architecture

Sender Profiles implement a hierarchical resource inheritance model:

Organization Level

The root entity that owns billing, compliance, and shared resources. Organizations serve as the administrative boundary and cost center.

Sender Profile Level

Individual messaging identities that can inherit resources from the organization or maintain dedicated resources. Each profile has its own API credentials, sending identity, and configuration.

Resource Level

Individual resources (billing, WhatsApp Business Account, contacts, templates, TCR) can be inherited, shared, or dedicated per profile.

This model enables use cases like agencies managing multiple client brands, franchises with centralized billing but local operations, or platforms offering white-labeled messaging.

Resource Governance Model

Each resource type implements a specific governance pattern:

ResourceInheritance PatternUse Case
BillingOrganization or DedicatedCentralized cost control vs. separate invoicing
WhatsApp Business AccountOrganization, Profile, or DedicatedShared WhatsApp number vs. brand-specific presence
ContactsInherit + Share toggleShared customer database vs. isolated lists
TemplatesInherit + Share toggleGlobal template library vs. brand-specific content
TCR Brand/CampaignFull/Partial/DedicatedUnified compliance vs. separate brand registration

Converting to an Organization

Sender Profiles require an organization structure. Converting a personal account creates the root entity and establishes the first sender profile:

Navigate to the Profiles page in the dashboard and click on the + Create Sender Profile button.

Create Initial Sender Profile

The first sender profile inherits from the organization by default and becomes your primary messaging identity.

Configure Resource Inheritance

For each resource, decide whether to inherit from the organization or maintain dedicated configuration.

📚 See Resource Configuration Patterns below for a detailed explanation of the different resource configuration patterns.

Sender Profile Created

The sender profile is created and you are redirected to the Sender Profiles page.

Resource Configuration Patterns

Billing Inheritance

Billing governance offers two modes:

  • Inherited: All charges flow to the organization's payment methods. Ideal for centralized cost management and single-invoice accounting.
  • Dedicated: Profile-specific billing with separate payment methods. Useful when clients or business units need direct billing.

Contacts and Templates

Contacts and templates implement a dual-mode sharing model:

  • Inheritance: Access resources from the organization level
  • Sharing: Allow this profile's resources to be accessed by other organization members

When sharing is enabled, any profile inheriting from the organization can access those resources. Consider data privacy implications when sharing contact lists across business units.

TCR Brand and Campaign

For US SMS compliance, TCR registration offers flexible inheritance:

  • Fully Inherited: Use organization's brand and campaign
  • Partially Inherited: Inherit brand but create dedicated campaign, or vice versa
  • Dedicated: Complete separate TCR registration for this profile

Managing Multiple Profiles

Profile Switching

The dashboard provides a profile selector to switch between sender profiles:

Profile Listing

View and manage all profiles within your organization:

Sender Profiles for Developers

API Credentials and Isolation

Each sender profile has unique API credentials. The API key determines which profile's resources are used:

curl -X POST "https://api.sent.dm/v3/messages" \
  -H "x-api-key: $PROFILE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": ["+1234567890"],
    "template": {"id": "tmpl_123"}
  }'
import SentDm from '@sentdm/sentdm';

// Uses profile-specific API key
const client = new SentDm({ apiKey: process.env.PROFILE_API_KEY });

await client.messages.send({
  to: ['+1234567890'],
  template: { id: 'tmpl_123' }
});
from sent_dm import SentDm

client = SentDm(api_key=os.environ['PROFILE_API_KEY'])

client.messages.send(
    to=["+1234567890"],
    template={"id": "tmpl_123"}
)
package main

import (
    "context"
    "os"
    "github.com/sentdm/sent-dm-go"
    "github.com/sentdm/sent-dm-go/option"
)

func main() {
    client := sentdm.NewClient(
        option.WithAPIKey(os.Getenv("PROFILE_API_KEY")),
    )

    client.Messages.Send(context.Background(), sentdm.MessageSendParams{
        To: []string{"+1234567890"},
        Template: sentdm.MessageSendParamsTemplate{
            ID: sentdm.String("tmpl_123"),
        },
    })
}
SentDmClient client = SentDmOkHttpClient.builder()
    .apiKey(System.getenv("PROFILE_API_KEY"))
    .build();

client.messages().send(MessageSendParams.builder()
    .addTo("+1234567890")
    .template(MessageSendParams.Template.builder()
        .id("tmpl_123")
        .build())
    .build());
SentDmClient client = new(apiKey: Environment.GetEnvironmentVariable("PROFILE_API_KEY"));

await client.Messages.SendAsync(new MessageSendParams
{
    To = new List<string> { "+1234567890" },
    Template = new MessageSendParamsTemplate { Id = "tmpl_123" }
});
use SentDM\Client;

$client = new Client($_ENV['PROFILE_API_KEY']);

$client->messages->send(
    to: ['+1234567890'],
    template: ['id' => 'tmpl_123']
);
require "sentdm"

client = Sentdm::Client.new(api_key: ENV["PROFILE_API_KEY"])

client.messages.send(
  to: ["+1234567890"],
  template: { id: "tmpl_123" }
)

Messages sent using a profile's API key automatically use that profile's configured resources—WhatsApp Business Account, templates, contacts, and TCR settings.

Multi-Tenant Application Pattern

For platforms serving multiple customers, map each customer to a sender profile:

async function sendOnBehalfOfCustomer(customerId: string, message: MessageRequest) {
  const profile = await db.senderProfiles.findByCustomer(customerId);
  const client = new SentDm({ apiKey: profile.apiKey });

  return client.messages.send({
    to: message.recipients,
    template: { id: message.templateId },
    variables: message.variables
  });
}
def send_on_behalf_of_customer(customer_id: str, message: dict):
    profile = db.sender_profiles.find_by_customer(customer_id)
    client = SentDm(api_key=profile.api_key)

    return client.messages.send(
        to=message["recipients"],
        template={"id": message["template_id"]},
        variables=message["variables"]
    )
func sendOnBehalfOfCustomer(customerID string, msg MessageRequest) (*sentdm.MessageResponse, error) {
    profile, _ := db.SenderProfiles.FindByCustomer(customerID)
    client := sentdm.NewClient(option.WithAPIKey(profile.APIKey))

    return client.Messages.Send(context.Background(), sentdm.MessageSendParams{
        To:       msg.Recipients,
        Template: sentdm.MessageSendParamsTemplate{ID: &msg.TemplateID},
    })
}
public MessageResponse sendOnBehalfOfCustomer(String customerId, MessageRequest message) {
    SenderProfile profile = db.senderProfiles().findByCustomer(customerId);
    SentDmClient client = SentDmOkHttpClient.builder()
        .apiKey(profile.getApiKey())
        .build();

    return client.messages().send(MessageSendParams.builder()
        .addTo(message.getRecipients().get(0))
        .template(MessageSendParams.Template.builder()
            .id(message.getTemplateId())
            .build())
        .build());
}
public async Task<MessageResponse> SendOnBehalfOfCustomer(string customerId, MessageRequest message)
{
    var profile = await db.SenderProfiles.FindByCustomerAsync(customerId);
    var client = new SentDmClient(apiKey: profile.ApiKey);

    return await client.Messages.SendAsync(new MessageSendParams
    {
        To = message.Recipients,
        Template = new MessageSendParamsTemplate { Id = message.TemplateId },
        Variables = message.Variables
    });
}
function sendOnBehalfOfCustomer(string $customerId, array $message): array
{
    $profile = $this->db->senderProfiles->findByCustomer($customerId);
    $client = new Client($profile->apiKey);

    return $client->messages->send(
        to: $message['recipients'],
        template: ['id' => $message['template_id']],
        variables: $message['variables'] ?? []
    );
}
def send_on_behalf_of_customer(customer_id, message)
  profile = db.sender_profiles.find_by_customer(customer_id)
  client = Sentdm::Client.new(api_key: profile.api_key)

  client.messages.send(
    to: message[:recipients],
    template: { id: message[:template_id] },
    variables: message[:variables]
  )
end

Webhook Handling with Profiles

Track usage per sender profile by looking up the message context from your database when webhooks arrive:

app.post('/webhooks/sent', async (req, res) => {
  res.sendStatus(200);
  const { field, payload } = req.body;

  if (field === 'messages' && payload.message_status === 'SENT') {
    // Retrieve sender profile context from your database
    const message = await db.messages.findBySentId(payload.message_id);

    if (message?.senderProfileId) {
      await analytics.track('message_sent', {
        senderProfileId: message.senderProfileId,
        messageId: payload.message_id,
        channel: payload.channel,
        inboundNumber: payload.inbound_number,
        timestamp: new Date()
      });
    }
  }
});
@app.post("/webhooks/sent")
async def webhook(request: Request):
    data = await request.json()
    p = data.get("payload", {})

    if data.get("field") == "messages" and p.get("message_status") == "SENT":
        # Retrieve sender profile context from your database
        message = db.messages.find_by_sent_id(p["message_id"])

        if message and message.sender_profile_id:
            analytics.track("message_sent", {
                "sender_profile_id": message.sender_profile_id,
                "message_id": p["message_id"],
                "channel": p["channel"],
                "inbound_number": p["inbound_number"],
                "timestamp": datetime.now().isoformat()
            })

    return {"status": "ok"}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
    var event struct {
        Field   string `json:"field"`
        Payload struct {
            MessageID     string `json:"message_id"`
            MessageStatus string `json:"message_status"`
            Channel       string `json:"channel"`
            InboundNumber string `json:"inbound_number"`
        } `json:"payload"`
    }
    json.NewDecoder(r.Body).Decode(&event)
    w.WriteHeader(http.StatusOK)

    if event.Field == "messages" && event.Payload.MessageStatus == "SENT" {
        // Retrieve sender profile context from your database
        message, _ := db.Messages.FindBySentID(event.Payload.MessageID)

        if message != nil && message.SenderProfileID != "" {
            analytics.Track("message_sent", map[string]interface{}{
                "sender_profile_id": message.SenderProfileID,
                "message_id":        event.Payload.MessageID,
                "channel":           event.Payload.Channel,
                "inbound_number":    event.Payload.InboundNumber,
            })
        }
    }
}
@PostMapping("/webhooks/sent")
public ResponseEntity<Void> webhook(@RequestBody WebhookEvent event) {
    if ("messages".equals(event.getField()) && "SENT".equals(event.getPayload().getMessageStatus())) {
        // Retrieve sender profile context from your database
        MessageRecord message = db.messages().findBySentId(event.getPayload().getMessageId());

        if (message != null && message.getSenderProfileId() != null) {
            analytics.track("message_sent", Map.of(
                "sender_profile_id", message.getSenderProfileId(),
                "message_id",        event.getPayload().getMessageId(),
                "channel",           event.getPayload().getChannel(),
                "inbound_number",    event.getPayload().getInboundNumber()
            ));
        }
    }
    return ResponseEntity.ok().build();
}
[ApiController]
[Route("webhooks")]
public class WebhookController : ControllerBase
{
    [HttpPost("sent")]
    public async Task<IActionResult> Webhook([FromBody] WebhookEvent evt)
    {
        if (evt.Field == "messages" && evt.Payload.MessageStatus == "SENT")
        {
            // Retrieve sender profile context from your database
            var message = await db.Messages.FindBySentIdAsync(evt.Payload.MessageId);

            if (message?.SenderProfileId != null)
            {
                await analytics.TrackAsync("message_sent", new {
                    sender_profile_id = message.SenderProfileId,
                    message_id        = evt.Payload.MessageId,
                    channel           = evt.Payload.Channel,
                    inbound_number    = evt.Payload.InboundNumber
                });
            }
        }
        return Ok();
    }
}
#[Post('/webhooks/sent')]
public function webhook(Request $request): JsonResponse
{
    $data = $request->getPayload()->all();
    $p = $data['payload'] ?? [];

    if (($data['field'] ?? '') === 'messages' && ($p['message_status'] ?? '') === 'SENT') {
        // Retrieve sender profile context from your database
        $message = $this->db->messages->findBySentId($p['message_id']);

        if ($message && $message->senderProfileId) {
            $this->analytics->track('message_sent', [
                'sender_profile_id' => $message->senderProfileId,
                'message_id'        => $p['message_id'],
                'channel'           => $p['channel'],
                'inbound_number'    => $p['inbound_number'],
            ]);
        }
    }

    return new JsonResponse(['status' => 'ok']);
}
post '/webhooks/sent' do
  request.body.rewind
  data = JSON.parse(request.body.read)
  p = data['payload'] || {}

  if data['field'] == 'messages' && p['message_status'] == 'SENT'
    # Retrieve sender profile context from your database
    message = db.messages.find_by_sent_id(p['message_id'])

    if message && message.sender_profile_id
      analytics.track('message_sent', {
        sender_profile_id: message.sender_profile_id,
        message_id:        p['message_id'],
        channel:           p['channel'],
        inbound_number:    p['inbound_number']
      })
    end
  end

  { status: 'ok' }.to_json
end

Architectural Patterns

Pattern 1: Agency Model

Centralized billing with dedicated WhatsApp Business Account per client:

  • Billing: Inherited (agency pays)
  • WhatsApp Business Account: Dedicated per profile (client branding)
  • Contacts: Dedicated (client data isolation)
  • Templates: Inherited + Shared (agency provides templates)

Pattern 2: Franchise Model

Shared resources with local brand presence:

  • Billing: Inherited (franchisor manages)
  • WhatsApp Business Account: Dedicated per location (local phone numbers)
  • Contacts: Inherited (shared customer base)
  • Templates: Inherited (franchise-wide messaging)

Pattern 3: Platform/ISV Model

Full tenant isolation:

  • Billing: Dedicated (tenants pay directly)
  • WhatsApp Business Account: Dedicated per tenant
  • Contacts: Dedicated
  • Templates: Dedicated

Best Practices

Security Considerations

  • Store profile API keys in separate secrets management systems for true isolation
  • Use webhook signature verification to ensure events originate from the expected profile
  • Implement rate limiting per profile to prevent one tenant from consuming quota

Cost Management

  • Use inherited billing for centralized cost control
  • Use dedicated billing when clients need direct invoicing
  • Monitor usage per profile through webhook events

Compliance

  • Use dedicated TCR registration when brands require separate identity
  • Use inherited contacts only when data sharing agreements permit
  • Document resource sharing policies for your organization

On this page